diff --git a/build.gradle.kts b/build.gradle.kts index 144271d..c1d76f8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -109,6 +109,9 @@ dependencies { testImplementation("io.projectreactor:reactor-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + // JSON 처리를 위한 Gson 라이브러리 + implementation("com.google.code.gson:gson:2.10.1") } @@ -223,26 +226,101 @@ tasks.named("bootJar") { // [수정 후] 'build' 태스크를 더 안전하게 // 기본 bootJar 태스크의 설정을 가져오기 위한 참조 val bootJar by tasks.getting(BootJar::class) +// +//// 'prod' 프로필이 내장된 JAR를 빌드하는 최종 태스크 정의 +//tasks.register("bootJarProd") { +// group = "build" +// description = "Builds a production JAR that defaults to the 'prod' profile." +// archiveClassifier.set("prod") +// +// // --- 필수 설정 복사 --- +// // 1. Main 클래스 설정 복사 +// mainClass.set(bootJar.mainClass) +// // 2. Classpath 설정 복사 +// classpath = bootJar.classpath +// // 3. Target Java Version 설정 복사 (이번 오류 해결) +// targetJavaVersion.set(bootJar.targetJavaVersion) +// +// manifest { +// attributes["Spring-Profiles-Active"] = "prod" +// } +//} -// 'prod' 프로필이 내장된 JAR를 빌드하는 최종 태스크 정의 -tasks.register("bootJarProd") { +// "local" 프로파일용 JAR를 빌드하는 작업 +tasks.register("bootJarLocal") { group = "build" - description = "Builds a production JAR that defaults to the 'prod' profile." - archiveClassifier.set("prod") + description = "로컬 환경용 JAR 파일을 빌드합니다 ('local' 프로파일 적용)." + archiveClassifier.set("local") // 파일 이름에 local 접미사 추가 (e.g., app-local.jar) - // --- 필수 설정 복사 --- - // 1. Main 클래스 설정 복사 - mainClass.set(bootJar.mainClass) - // 2. Classpath 설정 복사 - classpath = bootJar.classpath - // 3. Target Java Version 설정 복사 (이번 오류 해결) + // 메인 클래스와 클래스패스는 기본 bootJar 설정을 따라갑니다. + mainClass.set(tasks.bootJar.get().mainClass) + classpath = tasks.bootJar.get().classpath targetJavaVersion.set(bootJar.targetJavaVersion) - - manifest { - attributes["Spring-Profiles-Active"] = "prod" + // 'resources' 폴더의 모든 파일을 복사하되... + from("src/main/resources") { + include("**/*") + // prod 설정 파일은 제외합니다. + exclude("application-prod.properties") + // local 설정 파일의 이름을 application.properties로 변경합니다. + rename("application-local.properties", "application.properties") } } +// "prod" 프로파일용 JAR를 빌드하는 작업 +tasks.register("bootJarProd") { + group = "build" + description = "운영 환경용 JAR 파일을 빌드합니다 ('prod' 프로파일 적용)." + archiveClassifier.set("prod") // 파일 이름에 prod 접미사 추가 (e.g., app-prod.jar) + + // 메인 클래스와 클래스패스는 기본 bootJar 설정을 따라갑니다. + mainClass.set(tasks.bootJar.get().mainClass) + classpath = tasks.bootJar.get().classpath + targetJavaVersion.set(bootJar.targetJavaVersion) + // 'resources' 폴더의 모든 파일을 복사하되... + from("src/main/resources") { + include("**/*") + // local 설정 파일은 제외합니다. + exclude("application-local.properties") + // prod 설정 파일의 이름을 application.properties로 변경합니다. + rename("application-prod.properties", "application.properties") + } +} + + +// 🚀 1. 명령어를 실행할 새로운 Exec 태스크 정의 +tasks.register("runCommandAfterProdJar") { + group = "build" + description = "prod JAR 빌드 후 실행할 명령어를 정의합니다." + + // 이 태스크는 bootJarProd가 성공해야만 의미가 있으므로, 의존성을 명시해주는 것이 좋습니다. + dependsOn(tasks.named("bootJarProd")) + + // 실행할 OS 명령어와 인자를 설정합니다. + // 예시 1: Docker 이미지 빌드 + commandLine("docker", "buildx","buildx","--platform","linux/amd64", "-t", "lunaticbum/testjar:0.025", ".") + + // 예시 2: 빌드된 JAR 파일을 특정 서버로 복사 + // commandLine("scp", "build/libs/your-app-name-prod.jar", "user@server:/path/to/deploy") + + // 예시 3: 간단한 셸 스크립트 실행 + // commandLine("./deploy.sh") + + // 필요하다면 작업 디렉토리를 설정할 수 있습니다. + // workingDir = rootDir +// doLast { +// println("prod JAR 빌드가 완료되었습니다. 추가 명령어를 실행합니다.") +// exec { +// commandLine("docker", "push", "lunaticbum/testjar:0.025") +// // commandLine("echo", "Hello from doLast!") +// } +// } +} + +// 🚀 2. bootJarProd 태스크가 끝나면 위에서 정의한 태스크를 실행하도록 연결 +//tasks.named("bootJarProd") { +// finalizedBy(tasks.named("runCommandAfterProdJar")) +//} + // //// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결 //tasks.build { diff --git a/src/main/kotlin/WebConfig.kt b/src/main/kotlin/WebConfig.kt deleted file mode 100644 index 69b051d..0000000 --- a/src/main/kotlin/WebConfig.kt +++ /dev/null @@ -1,124 +0,0 @@ -//package kr.lunaticbum.back.lun.configs -// -//import io.netty.channel.ChannelOption -//import io.netty.handler.timeout.ReadTimeoutHandler -//import io.netty.handler.timeout.WriteTimeoutHandler -//import jakarta.servlet.ServletContext -//import jakarta.servlet.ServletException -//import kr.lunaticbum.back.lun.utils.LogService -//import lombok.RequiredArgsConstructor -//import org.springframework.beans.factory.annotation.Autowired -//import org.springframework.beans.factory.annotation.Qualifier -//import org.springframework.context.annotation.Bean -//import org.springframework.http.client.reactive.ReactorClientHttpConnector -//import org.springframework.web.WebApplicationInitializer -//import org.springframework.web.context.ContextLoaderListener -//import org.springframework.web.context.support.AnnotationConfigWebApplicationContext -//import org.springframework.web.reactive.function.client.ClientRequest -//import org.springframework.web.reactive.function.client.ClientResponse -//import org.springframework.web.reactive.function.client.ExchangeFilterFunction -//import org.springframework.web.reactive.function.client.WebClient -//import org.springframework.web.servlet.DispatcherServlet -//import org.springframework.web.servlet.HandlerInterceptor -//import org.springframework.web.servlet.config.annotation.InterceptorRegistry -//import org.springframework.web.util.DefaultUriBuilderFactory -//import reactor.core.publisher.Mono -//import reactor.netty.Connection -//import reactor.netty.http.client.HttpClient -//import java.time.Duration -//import java.util.concurrent.TimeUnit -//import java.util.function.Consumer -// -// -//@RequiredArgsConstructor -//class WebConfig : WebApplicationInitializer { -// -// lateinit var logService : LogService -// -// var factory: DefaultUriBuilderFactory = DefaultUriBuilderFactory() -// -// var httpClient: HttpClient = HttpClient.create() -// .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) // 10초 -// -// -// @Throws(ServletException::class) -// override fun onStartup(servletContext: ServletContext) { -// // Spring MVC 프로젝트 설정을 위해 작성하는 클래스의 객체를 생성한다. -// val servletAppContext = AnnotationConfigWebApplicationContext() -//// servletAppContext.register(ServletAppContext::class.java) -// -// // 요청 발생 시 요청을 처리하는 서블릿을 DispatcherServlet으로 설정해준다. -//// val dispatcherServlet = DispatcherServlet(servletAppContext) -//// val servlet = servletContext.addServlet("dispatcher", dispatcherServlet) -//// -//// // 부가 설정 -//// servlet.setLoadOnStartup(1) -//// servlet.addMapping("/") -// -// // Bean을 정의하는 클래스를 지정한다. -// val rootAppContext = AnnotationConfigWebApplicationContext() -// rootAppContext.register(RootAppContext::class.java) -// -// val listener = ContextLoaderListener(rootAppContext) -// servletContext.addListener(listener) -// } -// -// -// -// -// @Bean -// fun webClient(): WebClient { -// /** -// * 통신시 timeout 세팅 -// * - connect, read, write 를 모두 5000ms -// */ -// -// val httpClient = HttpClient.create() -// .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) -// .responseTimeout(Duration.ofMillis(5000)) -// .doOnConnected { conn: Connection -> -// conn.addHandlerLast(ReadTimeoutHandler(5000, TimeUnit.MILLISECONDS)) -// .addHandlerLast(WriteTimeoutHandler(5000, TimeUnit.MILLISECONDS)) -// } -// -// val webClient = WebClient.builder() -// .baseUrl("https://api.telegram.org/bot7934509464:AAE_xUbICxMdywLGnxo7BkeIqA1nVza4P9w") -// .clientConnector(ReactorClientHttpConnector(httpClient)) //생성한 HttpClient 연결 -// //Request Header 로깅 필터 -// .filter( -// ExchangeFilterFunction.ofRequestProcessor { clientRequest: ClientRequest -> -// logService.log(">>>>>>>>> REQUEST <<<<<<<<<<") -// logService.log("Request: ${clientRequest.method()} ${clientRequest.url()}") -// clientRequest.headers() -// .forEach { (name: String?, values: MutableList?) -> -// values.forEach( -// Consumer { value: String? -> -// logService.log( -// "${name} : ${value}" -// ) -// }) -// } -// Mono.just(clientRequest) -// } -// ) //Response Header 로깅 필터 -// .filter( -// ExchangeFilterFunction.ofResponseProcessor { clientResponse: ClientResponse -> -// logService.log(">>>>>>>>>> RESPONSE <<<<<<<<<<") -// clientResponse.headers().asHttpHeaders() -// .forEach { (name: String?, values: MutableList?) -> -// values.forEach( -// Consumer { value: String? -> -// logService.log( -// "${name} ${value}" -// ) -// }) -// } -// Mono.just(clientResponse) -// } -// ) -// .defaultHeader("Content-type", "application/x-www-form-urlencoded;charset=utf-8") //기본 헤더설정 -// .build() -// -// return webClient -// } -//} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/ApiIntegrationTest.kt b/src/main/kotlin/kr/lunaticbum/back/lun/ApiIntegrationTest.kt new file mode 100644 index 0000000..2fa9de3 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/ApiIntegrationTest.kt @@ -0,0 +1,117 @@ +//import com.google.gson.Gson +//import okhttp3.* +//import okhttp3.MediaType.Companion.toMediaType +//import okhttp3.RequestBody.Companion.asRequestBody +//import okhttp3.RequestBody.Companion.toRequestBody +//import java.io.File +//import java.io.IOException +// +//// Gson 파싱을 위한 데이터 클래스 +//data class LoginRequest(val userId: String, val userPw: String) +//data class LoginResponse(val token: String?) +// +///** +// * API 통합 테스트를 실행하는 메인 함수입니다. +// * IDE에서 직접 실행(▶)할 수 있습니다. +// */ +//fun main() { +// val tester = ApiIntegrationTest() +// tester.runBookmarkTest() +//} +// +//class ApiIntegrationTest { +// +// private val client = OkHttpClient() +// private val gson = Gson() +// private val jsonMediaType = "application/json; charset=utf-8".toMediaType() +// +// // --- 테스트 환경 설정 --- +// private val baseUrl = "http://localhost:443" +// private val testUserId = "lunaticbum" +// private val testUserPw = "VioPup*383" +// private val imageToUpload = File("test_image.jpg") // 프로젝트 루트에 있는 이미지 파일 +// +// /** +// * 로그인 API를 호출하여 JWT 토큰을 반환합니다. +// */ +// private fun loginAndGetToken(): String? { +// println("1. 로그인을 시도합니다...") +// +// val loginRequest = LoginRequest(userId = testUserId, userPw = testUserPw) +// val requestBody = gson.toJson(loginRequest).toRequestBody(jsonMediaType) +// +// val request = Request.Builder() +// .url("$baseUrl/api/auth/login") +// .post(requestBody) +// .build() +// +// try { +// client.newCall(request).execute().use { response -> +// if (!response.isSuccessful) { +// println("❌ 로그인 실패: ${response.code} - ${response.body?.string()}") +// return null +// } +// val responseBody = response.body?.string() +// val loginResponse = gson.fromJson(responseBody, LoginResponse::class.java) +// println("✅ 로그인 성공!") +// return loginResponse.token +// } +// } catch (e: IOException) { +// println("❌ 로그인 중 오류 발생: ${e.message}") +// return null +// } +// } +// +// /** +// * 발급받은 토큰을 사용하여 북마크 저장 API를 호출합니다. +// */ +// private fun saveBookmarkWithToken(token: String) { +// println("\n2. 발급받은 토큰으로 북마크 저장을 시도합니다... ${imageToUpload.absolutePath}") +// +// if (!imageToUpload.exists()) { +// println("❌ 파일 없음: '${imageToUpload.path}' 경로에 테스트 이미지가 존재하지 않습니다.") +// return +// } +// +// // Multipart 요청 본문 생성 +// val requestBody = MultipartBody.Builder() +// .setType(MultipartBody.FORM) +// .addFormDataPart( +// "bookmarkData", +// """{"url":"https://m.cafe.daum.net/dotax/Elgq/4636033","userComment":"Kotlin 테스트 코멘트","visibility":"PUBLIC"}""" +// ) +// .addFormDataPart( +// "imageFile", +// imageToUpload.name, +// imageToUpload.asRequestBody("image/jpeg".toMediaType()) +// ) +// .build() +// +// val request = Request.Builder() +// .url("$baseUrl/api/bookmarks/with-image") +// .header("Authorization", "Bearer $token") // 헤더에 JWT 토큰 추가 +// .post(requestBody) +// .build() +// +// try { +// client.newCall(request).execute().use { response -> +// println("✅ 북마크 저장 요청 완료! 응답 코드: ${response.code}") +// println("응답 내용: ${response.body?.string()}") +// } +// } catch (e: IOException) { +// println("❌ 북마크 저장 중 오류 발생: ${e.message}") +// } +// } +// +// /** +// * 전체 테스트 시나리오를 실행합니다. +// */ +// fun runBookmarkTest() { +// val token = loginAndGetToken() +// if (token != null) { +// saveBookmarkWithToken(token) +// } else { +// println("\n테스트 중단: 로그인에 실패하여 북마크 저장을 진행할 수 없습니다.") +// } +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt index 93dc5a8..0c174a6 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt @@ -1,6 +1,8 @@ package kr.lunaticbum.back.lun.configs import com.fasterxml.jackson.databind.ObjectMapper +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.MalformedJwtException import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import kr.lunaticbum.back.lun.model.MongoPersistentTokenRepository @@ -9,6 +11,7 @@ import kr.lunaticbum.back.lun.utils.LogService import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.core.annotation.Order import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus import org.springframework.http.MediaType @@ -31,12 +34,21 @@ import org.springframework.web.ErrorResponse import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfigurationSource import org.springframework.web.cors.UrlBasedCorsConfigurationSource - +import jakarta.servlet.FilterChain +import kr.lunaticbum.back.lun.utils.JwtUtil +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.web.filter.OncePerRequestFilter +import java.security.SignatureException @Configuration @EnableWebSecurity @EnableMethodSecurity // @PreAuthorize 어노테이션을 사용하기 위해 추가 class SecurityConfig( + private val jwtUtil: JwtUtil, private val userManager: UserManager, private val bCryptPasswordEncoder: BCryptPasswordEncoder, private val tokenRepository: MongoPersistentTokenRepository @@ -48,7 +60,7 @@ class SecurityConfig( fun webSecurityCustomizer(): WebSecurityCustomizer { // 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다. return WebSecurityCustomizer { web -> - web.ignoring().requestMatchers("/api/images/**", "/images/**") + web.ignoring().requestMatchers( "/images/**") } } @@ -74,15 +86,53 @@ class SecurityConfig( return source } + @Bean + @Order(1) // API 보안 설정을 먼저 적용 + fun apiFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .securityMatcher("/api/**") // 이 설정은 /api/ 경로에만 적용됨 + .csrf { it.disable() } + .cors { it.configurationSource(corsConfigurationSource()) } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음 + .authorizeHttpRequests { auth -> + auth + .requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용 + .anyRequest().authenticated() // 나머지 API는 인증 필요 + } + .exceptionHandling { handling -> + handling.authenticationEntryPoint(jwtAuthenticationEntryPoint()) + } + // 모든 API 요청 전에 JWT 토큰을 검증하는 필터 추가 + .addFilterBefore(JwtAuthenticationFilter(jwtUtil, userManager), UsernamePasswordAuthenticationFilter::class.java) + + return http.build() + } @Bean - fun filterChain(http: HttpSecurity): SecurityFilterChain { + fun jwtAuthenticationEntryPoint(): AuthenticationEntryPoint { + return AuthenticationEntryPoint { request, response, authException -> + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.contentType = MediaType.APPLICATION_JSON_VALUE + val body = mapOf( + "status" to HttpServletResponse.SC_UNAUTHORIZED, + "error" to "Unauthorized", + "message" to (authException.message ?: "JWT Authentication Failed"), + "path" to request.servletPath + ) + ObjectMapper().writeValue(response.outputStream, body) + } + } + + @Bean + @Order(2) // 웹 페이지 보안 설정 + fun webFilterChain(http: HttpSecurity): SecurityFilterChain { http.cors { } .csrf { csrf -> csrf.ignoringRequestMatchers( "/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx", - "/api/ranks/submit", // 통합 랭킹 API - "/puzzle/**", // <-- 이 줄을 추가하세요. + "/api/ranks/submit", + "/bums/save/loc.api", + "/puzzle/**", ) }.authorizeHttpRequests { auth -> auth @@ -93,6 +143,7 @@ class SecurityConfig( // 2. 공개 GET API 및 페이지 = permitAll .requestMatchers(HttpMethod.GET, + "/api/images/**", "/", "/home.bs", "/bums/where.bs", "/user/login.bs", "/user/join.bs", "/blog/viewer/**", "/blog/posts", @@ -100,7 +151,9 @@ class SecurityConfig( "/blog/posts/{postId}/comments.bjx", "/blog/comments/{commentId}/replies.bjx", "/blog/categories.bjx", "/blog/hashtags.bjx", "/puzzle/**", "/api/ranks/list", "/licenses", - "/puzzle/images/**" + "/puzzle/images/**", + "/bums/face.bs", // [추가] 사이트 소개 페이지 + "/bookmarks/**", // [추가] 북마크 목록 페이지 ).permitAll() // 3. 공개 POST API = permitAll @@ -108,10 +161,12 @@ class SecurityConfig( "/user/login.bjx", "/user/joinUser.bjx", "/api/ranks/submit", "/bums/save/loc.api", - "/puzzle/**", // <-- 이 줄을 추가하세요. - // [수정] 와일드카드를 사용하여 모든 게시물의 좋아요/싫어요 허용 + "/puzzle/**", + "/tlg/repotToMe.bjx", "/blog/post/*/like.bjx", - "/blog/post/*/unlike.bjx" + "/blog/post/*/unlike.bjx", + "/bookmarks/*/like", // [추가] 북마크 좋아요 + "/bookmarks/*/unlike" // [추가] 북마크 싫어요 ).permitAll() // 4. 'WRITE' 또는 'ADMIN' 권한이 필요한 요청 @@ -124,15 +179,16 @@ class SecurityConfig( // 5. 'ADMIN' 권한이 필요한 요청 (my_info.html의 관리자 기능) .requestMatchers( "/user/approve-writer/**", "/user/reject-writer/**", - "/blog/post/*/block", "/blog/post/*/unblock" + "/blog/post/*/block", "/blog/post/*/unblock", + "/api/images/*/approve-banner", + "/api/images/*/revoke-banner" ).hasRole("ADMIN") // 6. 나머지 모든 요청 = authenticated (인증 필요) .anyRequest().authenticated() - }.formLogin { form -> form.loginPage("/home.bs?action=login") - .loginProcessingUrl("/user/login.bs") // 로그인 처리 URL 명시 (선택사항) + .loginProcessingUrl("/user/login.bs") .defaultSuccessUrl("/", true) .permitAll() }.rememberMe { rememberMe -> @@ -143,6 +199,10 @@ class SecurityConfig( .userDetailsService(userManager) }.logout { logout -> logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll() + }.exceptionHandling { handling -> + handling + .authenticationEntryPoint(unauthorizedEntryPoint) // 인증되지 않은 사용자가 접근 시 + .accessDeniedHandler(accessDeniedHandler) // 인증은 되었으나 권한이 없는 사용자가 접근 시 } return http.build() } @@ -187,4 +247,52 @@ class SecurityConfig( fun bCryptPasswordEncoder(): BCryptPasswordEncoder { return BCryptPasswordEncoder() } +} + +class JwtAuthenticationFilter( + private val jwtUtil: JwtUtil, + private val userManager: UserManager +) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val authHeader = request.getHeader("Authorization") + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response) + return + } + try { + val jwt = authHeader.substring(7) + val username = jwtUtil.extractUsername(jwt) + + if (SecurityContextHolder.getContext().authentication == null) { + val userDetails = this.userManager.loadUserByUsername(username) + if (jwtUtil.isTokenValid(jwt, userDetails)) { + println("jwtUtil.isTokenValid($jwt, $userDetails)") + val authToken = UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.authorities + ) + authToken.details = WebAuthenticationDetailsSource().buildDetails(request) + println("authToken.details >>> ${authToken.details}") + SecurityContextHolder.getContext().authentication = authToken + } + } + + } catch (e: ExpiredJwtException) { + println("JWT Error: Token has expired - ${e.message}") + } catch (e: SignatureException) { + println("JWT Error: Signature validation failed - ${e.message}") + } catch (e: MalformedJwtException) { + println("JWT Error: Malformed token - ${e.message}") + } catch (e: Exception) { + println("JWT Error: Could not set user authentication in security context - ${e.message}") + } + filterChain.doFilter(request, response) + println("JWT Token validated") + } } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/WebClientConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/WebClientConfig.kt new file mode 100644 index 0000000..1cc7850 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/WebClientConfig.kt @@ -0,0 +1,30 @@ +package kr.lunaticbum.back.lun.configs + +import io.netty.channel.ChannelOption +import io.netty.handler.timeout.ReadTimeoutHandler +import io.netty.handler.timeout.WriteTimeoutHandler +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient +import java.util.concurrent.TimeUnit + +@Configuration +class WebClientConfig { + + @Bean + fun webClient(): WebClient { + // Netty HttpClient에 타임아웃 설정 + val httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) // 연결 타임아웃 5초 + .doOnConnected { conn -> + conn.addHandlerLast(ReadTimeoutHandler(5, TimeUnit.SECONDS)) // 읽기 타임아웃 5초 + .addHandlerLast(WriteTimeoutHandler(5, TimeUnit.SECONDS)) // 쓰기 타임아웃 5초 + } + + return WebClient.builder() + .clientConnector(ReactorClientHttpConnector(httpClient)) + .build() + } +} diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt index 03ce4fe..d1ac0ae 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt @@ -1,21 +1,29 @@ package kr.lunaticbum.back.lun.controllers import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue import com.google.gson.Gson import com.google.gson.JsonParser +import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull +import kr.lunaticbum.back.lun.configs.GlobalEnvironment import kr.lunaticbum.back.lun.model.* import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.plainText import net.coobird.thumbnailator.Thumbnails import org.jsoup.Jsoup import org.jsoup.nodes.Element +import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Page import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize @@ -28,6 +36,7 @@ import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile import reactor.core.publisher.Flux import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers import java.io.File import java.io.IOException import java.net.URLDecoder @@ -39,6 +48,7 @@ import java.nio.file.StandardCopyOption import java.text.SimpleDateFormat import java.util.* import javax.imageio.ImageIO +import kotlin.io.path.exists // --- API 응답을 위한 DTO (Data Transfer Object) 클래스들 --- @@ -125,7 +135,7 @@ class BlogController( // 썸네일 생성 및 경로 설정 generateThumbnail(filename, 200) // 너비 200px 썸네일 생성 val thumbFilename = filename.substringBeforeLast(".") + "_thumbnail." + filename.substringAfterLast(".") - post.thumb = "/api/images/$thumbFilename" + post.thumb = "/api/images/$thumbFilename?type=thumbnail" } else { // 게시물에 이미지가 없는 경우, 기본 썸네일을 지정합니다. post.image = null @@ -145,42 +155,94 @@ class BlogController( */ @GetMapping("/api/images/{filename:.+}") @ResponseBody - fun getImage(@PathVariable filename: String): ResponseEntity { + fun getImage( + @PathVariable filename: String, + @RequestParam(required = false) type: String? // "thumbnail" 같은 타입 요청 + ): ResponseEntity { if (uploadPath.isNullOrBlank()) { return ResponseEntity.notFound().build() } try { - val imagePath: Path = Paths.get(uploadPath, filename) - // 보안: 요청된 파일이 실제 업로드 경로 내에 있는지 확인 (Path Traversal 공격 방지) - if (!imagePath.normalize().startsWith(Paths.get(uploadPath).normalize())) { - return ResponseEntity.badRequest().build() + logService.log("req $filename ") + // 1. 요청 타입이 없으면 원본 이미지를 반환 + if (type.isNullOrBlank()) { + val originalPath = Paths.get(uploadPath, filename) + return serveImage(originalPath, filename) } - if (Files.exists(imagePath) && Files.isReadable(imagePath)) { - val imageBytes = Files.readAllBytes(imagePath) - - // 파일 확장자를 기반으로 적절한 Content-Type 헤더 설정 - val contentType = when (filename.substringAfterLast('.').lowercase()) { - "jpg", "jpeg" -> MediaType.IMAGE_JPEG - "png" -> MediaType.IMAGE_PNG - "gif" -> MediaType.IMAGE_GIF - else -> MediaType.APPLICATION_OCTET_STREAM + // 2. 요청 타입에 따라 캐시 파일 이름과 목표 너비 설정 + val (targetWidth, resizedFilename) = when (type) { + "thumbnail" -> { + val baseName = filename.substringBeforeLast(".") + val extension = filename.substringAfterLast(".") + Pair(400, "${baseName}_thumbnail.${extension}") // 썸네일 너비: 400px } - - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"$filename\"") - .contentType(contentType) - .body(imageBytes) + "banner" -> { + val baseName = filename.substringBeforeLast(".") + val extension = filename.substringAfterLast(".") + Pair(1200, "${baseName}_banner.${extension}") // 썸네일 너비: 400px + } + // 필요하다면 다른 타입 추가 (예: "medium" -> 800) + else -> Pair(null, null) } + + if (targetWidth == null || resizedFilename == null) { + val originalPath = Paths.get(uploadPath, filename) + return serveImage(originalPath, filename) // 지원하지 않는 타입이면 원본 반환 + } + + // 3. 캐시 파일 경로 확인 + val resizedPath = Paths.get(uploadPath, resizedFilename) + + // 4. 캐시 파일이 이미 존재하면 바로 반환 + if (Files.exists(resizedPath)) { + return serveImage(resizedPath, resizedFilename) + } + + // 5. 캐시 파일이 없으면 원본을 찾아 리사이즈 후 저장 (캐싱) + val originalPath = Paths.get(uploadPath, filename) + if (!Files.exists(originalPath)) { + return ResponseEntity.notFound().build() + } + + Thumbnails.of(originalPath.toFile()) + .width(targetWidth) + .keepAspectRatio(true) + .outputQuality(0.85) + .toFile(resizedPath.toFile()) + + // 6. 새로 생성된 캐시 파일을 반환 + return serveImage(resizedPath, resizedFilename) + } catch (e: IOException) { logService.log("Error reading image file: $filename, Error: ${e.message}") - // 파일을 읽는 중 오류가 발생하면 500 서버 에러를 반환할 수 있습니다. return ResponseEntity.internalServerError().build() } + } - // 파일이 존재하지 않으면 404 Not Found 응답 - return ResponseEntity.notFound().build() + // 이미지 파일을 읽어 ResponseEntity로 만드는 헬퍼 함수 + private fun serveImage(imagePath: Path, filename: String): ResponseEntity { + if (!Files.exists(imagePath) || !Files.isReadable(imagePath)) { + return ResponseEntity.notFound().build() + } + // 보안: 요청된 파일이 실제 업로드 경로 내에 있는지 확인 + if (!imagePath.normalize().startsWith(Paths.get(uploadPath).normalize())) { + return ResponseEntity.badRequest().build() + } + + val imageBytes = Files.readAllBytes(imagePath) + val contentType = when (filename.substringAfterLast('.').lowercase()) { + "jpg", "jpeg" -> MediaType.IMAGE_JPEG + "png" -> MediaType.IMAGE_PNG + "gif" -> MediaType.IMAGE_GIF + else -> MediaType.APPLICATION_OCTET_STREAM + } + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"$filename\"") + .contentType(contentType) + .body(imageBytes) } /** @@ -240,7 +302,7 @@ class BlogController( @ResponseBody suspend fun home(): ResultMV { val vm = ResultMV("content/home") - val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg" + val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg?type=banner" try { var bannerImagePath: String? = null @@ -249,9 +311,9 @@ class BlogController( if (randomImage != null && !randomImage.path.isNullOrBlank()) { // 1. 이미지 경로가 예전 방식인지 확인하고 수정합니다. if (randomImage.path.contains("/blog/post/images/")) { - bannerImagePath = randomImage.path.replace("/blog/post/images/", "/api/images/") + bannerImagePath = randomImage.path.replace("/blog/post/images/", "/api/images/") +"?type=banner" } else { - bannerImagePath = randomImage.path + bannerImagePath = randomImage.path +"?type=banner" } } @@ -371,6 +433,7 @@ class BlogController( @GetMapping(value = ["/blog/edit", "/blog/edit/{postId}"]) suspend fun editPost( @PathVariable(required = false) postId: String?, + @RequestParam(required = false) type: String?, // [추가] 'type' 파라미터 받기 @AuthenticationPrincipal userDetails: UserDetails? ): ResultMV { if (userDetails == null) { @@ -394,6 +457,10 @@ class BlogController( // 사용자의 의도대로 기본 제목을 설정합니다. title = "무제(無題) (${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(java.util.Date())})" content = "" // 내용은 비워둡니다. + if (type == PostType.ABOUT_SITE.name) { + this.postType = PostType.ABOUT_SITE.name + vm.modelMap["pageTitle"] = "사이트 소개글 작성" + } } vm.modelMap["srcPost"] = newPost vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(newPost) @@ -551,7 +618,8 @@ class BlogController( readCount = originalPost.readCount, voteCount = originalPost.voteCount, unlikeCount = originalPost.unlikeCount, - modifyTime = System.currentTimeMillis() + modifyTime = System.currentTimeMillis(), + postType = originalPost.postType // [추가] 기존 postType을 새 버전에 복사 ) postManager.save(newVersion).map { savedPost -> PostSaveResponse(0, "Success", PostIdData(savedPost.id!!)) @@ -669,4 +737,381 @@ class BlogController( @GetMapping("/licenses") fun licenses() = ResultMV("content/licenses") -} \ No newline at end of file +} + + + +@RestController +@RequestMapping("/bums") +class BumsPrivate { + @Autowired + lateinit var globalEvv : GlobalEnvironment + + @Autowired + lateinit var logService: LogService + + @Autowired + lateinit var postManager : PostManager + + @Autowired + lateinit var locationService: LocationLogService + @GetMapping("face.bs") + suspend fun aboutMePage(): ResultMV { + val vm = ResultMV("content/about_view") // 소개글 전용 뷰 템플릿 사용 + + // 'ABOUT_SITE' 타입의 가장 최신 글을 찾아 모델에 추가 + val aboutPost = postManager.findLatestAboutPost().awaitSingleOrNull() + + if (aboutPost != null) { + vm.modelMap["srcPost"] = aboutPost + vm.modelMap["srcPostJson"] = com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(aboutPost) + vm.setTitle("BUM'sPace 소개") + } else { + // 글이 없을 경우를 대비한 처리 + vm.modelMap["srcPost"] = Post(title = "소개글이 아직 작성되지 않았습니다.", content = "") + vm.modelMap["srcPostJson"] = "{}" + vm.setTitle("소개글 없음") + } + return vm + } + + @GetMapping("where.bs") + fun where(@RequestParam(value = "page", defaultValue = "0") page: Int) : ResultMV { // (1) page 파라미터 받기 + val m = ResultMV("content/private/where") + + // (2) Pageable 객체 생성: 현재 페이지(page), 페이지당 20개, ID 역순 정렬 (최신순) + // 예시: 날짜 필드명이 "createdAt"일 경우 + val pageable: Pageable = PageRequest.of(page, 30, Sort.by("time").descending()) + + // (3) 서비스 호출 변경 (List 대신 Page 객체를 반환하는 메서드 호출) + // 참고: locationService에 findAll(Pageable) 메서드가 구현되어 있어야 합니다. + val locationPage: Page = locationService.findAll(pageable) + + // (4) 모델에 Page 객체 전체를 전달 + m.modelMap.put("locationPage", locationPage) + + m.setTitle("돼지 여기있다요~!!") + return m + } + + @ResponseBody + @PostMapping("save/loc.api") + fun login(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity { + logService.log("${httpServletRequest.requestURI}") + logService.log(jsonString) + + var location : LocationLog? = null + jsonString.plainText().let { + Gson().fromJson(it, LocationLog::class.java)?.let { model -> + location = model + logService.log(model.toString()) + locationService.save(model) + } + } + val responce = ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply { + + }) +// CoroutineScope(Dispatchers.IO).launch { +// location?.let { +// val client = WebClient.create() +// client.get() +// .uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${it.mAddressLines.first()} 저장") +// .retrieve() +// .bodyToMono(String::class.java).block() ?: "FAIL" +// } +// } + return responce + } + +} +@RestController +@RequestMapping("/api/og") +class OpenGraphController(private val logService: LogService) { + + // Jsoup 라이브러리를 사용하기 위해 의존성 추가가 필요할 수 있습니다. + // build.gradle.kts 파일에 implementation("org.jsoup:jsoup:1.15.3") 추가 + + /** + * 전달받은 URL의 Open Graph 메타 태그 정보를 파싱하여 반환합니다. + */ + @GetMapping("/parse") + fun fetchOpenGraphData(@RequestParam url: String): Mono>> { + return Mono.fromCallable { + try { + // Jsoup으로 URL에 접속하여 HTML 문서를 가져옴 + val doc = Jsoup.connect(url).get() + + // "og:title", "og:description", "og:image" 메타 태그를 찾음 + val title = doc.select("meta[property=og:title]").attr("content") + val description = doc.select("meta[property=og:description]").attr("content") + val imageUrl = doc.select("meta[property=og:image]").attr("content") + + // 찾은 정보를 Map에 담아 성공 응답(200 OK)으로 반환 + val data = mapOf( + "title" to (title.ifEmpty { doc.title() }), // og:title 없으면 그냥 title 태그 사용 + "description" to description, + "thumbnailUrl" to imageUrl + ) + ResponseEntity.ok(data) + } catch (e: java.net.SocketTimeoutException) { + // 타임아웃 예외를 명시적으로 처리 + logService.log("OG data parsing timed out for URL: $url") + ResponseEntity.status(408).body(mapOf("error" to "요청 시간이 초과되었습니다.")) + } catch (e: Exception) { + logService.log("OG data parsing failed for URL: $url, Error: ${e.message}") + // 파싱 실패 시 에러 응답 + ResponseEntity.badRequest().body(mapOf("error" to "URL 정보를 가져올 수 없습니다.")) + } + }.subscribeOn(Schedulers.boundedElastic()) // I/O 작업을 별도 스레드에서 처리 + } +} + +@Controller +@RequestMapping("/bookmarks") +class BookmarkController(private val bookmarkService: WebBookmarkService, + + private val imageMetaService: ImageMetaService, + private val commentService: CommentService, // [신규 추가] CommentService 주입 + private val objectMapper: ObjectMapper // [신규 추가] JSON 처리를 위해 주입 + ) { + + @GetMapping + suspend fun bookmarkListPage( + @RequestParam(value = "page", defaultValue = "0") page: Int, + @AuthenticationPrincipal userDetails: UserDetails? + ): ResultMV { + val vm = ResultMV("content/bookmarks") // 북마크 전용 뷰 템플릿 + val pageable = PageRequest.of(page, 9) // 한 페이지에 9개씩 (3x3 그리드) + + // 서비스 레이어를 호출하여 현재 사용자 권한에 맞는 북마크 목록을 가져옴 + val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle() + + vm.modelMap["bookmarksPage"] = bookmarksPage + vm.setTitle("저장된 페이지 목록") + return vm + } + + + @PostMapping("/{bookmarkId}/like") + @ResponseBody + fun likeBookmark(@PathVariable bookmarkId: String): Mono { + return bookmarkService.incrementVote(bookmarkId).map { + VoteResponse(it.voteCount, it.unlikeCount) + } + } + + @PostMapping("/{bookmarkId}/unlike") + @ResponseBody + fun unlikeBookmark(@PathVariable bookmarkId: String): Mono { + return bookmarkService.incrementUnlike(bookmarkId).map { + VoteResponse(it.voteCount, it.unlikeCount) + } + } + + @GetMapping("/{bookmarkId}/comments") + @ResponseBody + fun getComments(@PathVariable bookmarkId: String): Mono { + // 기존 CommentService의 메소드를 그대로 호출. + // 서비스는 ID가 post의 것인지 bookmark의 것인지 구분할 필요 없음. + return commentService.getCommentsForPost(bookmarkId) + .collectList() + .map { comments -> CommentResponse(0, "Success", comments) } + } + + @PostMapping("/{bookmarkId}/comments") + @ResponseBody + fun addComment( + @PathVariable bookmarkId: String, + @RequestBody rawPayload: String, + @AuthenticationPrincipal user: UserDetails? + ): Mono { + val comment = PayloadDecoder.decode(rawPayload, Comment::class.java, objectMapper) + + // **핵심**: Comment 객체의 postId 필드에 bookmarkId를 설정 + comment.postId = bookmarkId + comment.writer = user?.username ?: "Anonymous" + comment.writeTime = System.currentTimeMillis() + + // 기존 CommentService를 사용하여 댓글 저장 + return commentService.addComment(comment) + .map { CommentResponse(0, "Success") } + } + + @Value("\${image.upload.path}") + private val uploadPath: String? = null + + /** + * 북마크 목록을 페이지네이션으로 조회하는 API + * (예: GET /api/bookmarks?page=0&size=10) + */ + @GetMapping("/list") + suspend fun getBookmarkList( + @AuthenticationPrincipal userDetails: UserDetails?, + pageable: Pageable + ): ResponseEntity> { + val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle() + return ResponseEntity.ok(bookmarksPage) + } + + /** + * 새 북마크를 저장하는 API + */ + @PostMapping("/save") + fun saveBookmark( + @RequestBody request: Map, + @AuthenticationPrincipal user: UserDetails? + ): Mono> { + if (user == null) { + // 이 요청은 인증이 필요하므로 user가 null일 수 없음 (SecurityConfig에서 보장) + return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()) + } + + val url = request["url"] ?: return Mono.just(ResponseEntity.badRequest().build()) + + val newBookmark = WebBookmark( + userId = user.username, + url = url, + userComment = request["userComment"], + visibility = request["visibility"] ?: "PRIVATE", + metadataStatus = "PENDING" + ) + + return bookmarkService.saveBookmark(newBookmark) + .map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) } + } + + data class BookmarkDataDto( + val url: String, + val userComment: String?, + val visibility: String? + ) + + @PostMapping("api/with-image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun saveBookmarkWithImage( + @RequestPart("imageFile") imageFile: MultipartFile, + @RequestPart("bookmarkData") bookmarkDataJson: String, // 북마크 데이터는 JSON 문자열로 받음 + @AuthenticationPrincipal user: UserDetails? + ): Mono> { + if (user == null || uploadPath.isNullOrBlank()) { + return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()) + } + + // 1. 이미지 파일 저장 + val uniqueFilename = "${UUID.randomUUID()}_${imageFile.originalFilename}" + val targetPath = Paths.get(uploadPath, uniqueFilename) + try { + Files.createDirectories(targetPath.parent) + imageFile.transferTo(targetPath.toFile()) + } catch (e: Exception) { + return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()) + } + + // 2. 북마크 데이터 (JSON 문자열)를 DTO 객체로 변환 + val bookmarkData: BookmarkDataDto = objectMapper.readValue(bookmarkDataJson) + + // 3. WebBookmark 객체 생성 + val newBookmark = WebBookmark( + userId = user.username, + url = bookmarkData.url, + userComment = bookmarkData.userComment, + visibility = bookmarkData.visibility ?: "PRIVATE", + metadataStatus = "PENDING", + // 저장된 이미지의 서버 URL을 저장 + userSelectedImageUrl = "/api/images/$uniqueFilename" + ) + + // 4. 북마크 정보 DB에 저장 + return bookmarkService.saveBookmark(newBookmark) + .map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) } + } + +} + + +@RestController +@RequestMapping("/api/bookmarks") +class BookmarkApiController( + private val bookmarkService: WebBookmarkService, + private val imageMetaService: ImageMetaService, + private val commentService: CommentService, // [신규 추가] CommentService 주입 + private val objectMapper: ObjectMapper, // [신규 추가] JSON 처리를 위해 주입 + private val logService: LogService, +) { + + + @Value("\${image.upload.path}") + private val uploadPath: String? = null + + /** + * 북마크 목록을 페이지네이션으로 조회하는 API + * (예: GET /api/bookmarks?page=0&size=10) + */ + @GetMapping("/list") + suspend fun getBookmarkList( + @AuthenticationPrincipal userDetails: UserDetails?, + pageable: Pageable + ): ResponseEntity> { + val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle() + return ResponseEntity.ok(bookmarksPage) + } + + + data class BookmarkDataDto( + val url: String, + val userComment: String?, + val visibility: String? + ) + + @PostMapping("/with-image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun saveBookmarkWithImage( + @RequestPart("imageFile") imageFile: MultipartFile, + @RequestPart("bookmarkData") bookmarkDataJson: String, // 북마크 데이터는 JSON 문자열로 받음 + @AuthenticationPrincipal user: UserDetails? + ): Mono> { + logService.log("uploadPath >>> ${uploadPath}") + if (user == null || uploadPath.isNullOrBlank()) { + return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()) + } + + + // Gson과 같은 JSON 라이브러리를 사용해 문자열을 DTO 객체로 변환할 수 있습니다. + // val gson = Gson() + // val bookmarkData = gson.fromJson(bookmarkDataJson, BookmarkData::class.java) + + println("✅ ${user.username} 사용자가 엔드포인트를 호출했습니다.") + println("전달받은 URL: ${/*bookmarkData.url*/ bookmarkDataJson}") // 예시 출력 + println("전달받은 이미지: ${imageFile.originalFilename} (크기: ${imageFile.size} 바이트)") + + // 1. 이미지 파일 저장 + val uniqueFilename = "${UUID.randomUUID()}_${imageFile.originalFilename}" + val targetPath = Paths.get(uploadPath, uniqueFilename) + try { + imageFile.transferTo(targetPath.toFile()) + } catch (e: Exception) { + e.printStackTrace() + println("IMAGE TRAN FAIL") + return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()) + } + + // 2. 북마크 데이터 (JSON 문자열)를 DTO 객체로 변환 + val bookmarkData: BookmarkDataDto = objectMapper.readValue(bookmarkDataJson) + + // 3. WebBookmark 객체 생성 + val newBookmark = WebBookmark( + userId = user.username, + url = bookmarkData.url, + userComment = bookmarkData.userComment, + visibility = bookmarkData.visibility ?: "PRIVATE", + metadataStatus = "PENDING", + // 저장된 이미지의 서버 URL을 저장 + userSelectedImageUrl = "/api/images/$uniqueFilename" + ) + println("newBookmark ${newBookmark}") + // 4. 북마크 정보 DB에 저장 + return bookmarkService.saveBookmark(newBookmark) + .map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }.apply { + println("OK") + } + } + +} diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BumsPrivate.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BumsPrivate.kt deleted file mode 100644 index 5ba1069..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BumsPrivate.kt +++ /dev/null @@ -1,80 +0,0 @@ -package kr.lunaticbum.back.lun.controllers - -import com.google.gson.Gson -import jakarta.servlet.http.HttpServletRequest -import kr.lunaticbum.back.lun.configs.GlobalEnvironment -import kr.lunaticbum.back.lun.model.* -import kr.lunaticbum.back.lun.utils.LogService -import kr.lunaticbum.back.lun.utils.plainText -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.data.domain.Page -import org.springframework.http.MediaType -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* -// Spring Data Paging을 위한 Import 추가 -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Pageable -import org.springframework.data.domain.Sort -import reactor.core.publisher.Flux - -@RestController -@RequestMapping("/bums") -class BumsPrivate { - @Autowired - lateinit var globalEvv : GlobalEnvironment - - @Autowired - lateinit var logService: LogService - - @Autowired - lateinit var locationService: LocationLogService - - @GetMapping("where.bs") - fun where(@RequestParam(value = "page", defaultValue = "0") page: Int) : ResultMV { // (1) page 파라미터 받기 - val m = ResultMV("content/private/where") - - // (2) Pageable 객체 생성: 현재 페이지(page), 페이지당 20개, ID 역순 정렬 (최신순) - // 예시: 날짜 필드명이 "createdAt"일 경우 - val pageable: Pageable = PageRequest.of(page, 30, Sort.by("time").descending()) - - // (3) 서비스 호출 변경 (List 대신 Page 객체를 반환하는 메서드 호출) - // 참고: locationService에 findAll(Pageable) 메서드가 구현되어 있어야 합니다. - val locationPage: Page = locationService.findAll(pageable) - - // (4) 모델에 Page 객체 전체를 전달 - m.modelMap.put("locationPage", locationPage) - - m.setTitle("돼지 여기있다요~!!") - return m - } - - @ResponseBody - @PostMapping("save/loc.api") - fun login(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity { - logService.log("${httpServletRequest.requestURI}") - logService.log(jsonString) - - var location : LocationLog? = null - jsonString.plainText().let { - Gson().fromJson(it, LocationLog::class.java)?.let { model -> - location = model - logService.log(model.toString()) - locationService.save(model) - } - } - val responce = ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply { - - }) -// CoroutineScope(Dispatchers.IO).launch { -// location?.let { -// val client = WebClient.create() -// client.get() -// .uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${it.mAddressLines.first()} 저장") -// .retrieve() -// .bodyToMono(String::class.java).block() ?: "FAIL" -// } -// } - return responce - } - -} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/GameRankController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/GameRankController.kt deleted file mode 100644 index c6833bd..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/GameRankController.kt +++ /dev/null @@ -1,48 +0,0 @@ -package kr.lunaticbum.back.lun.controllers - -import kr.lunaticbum.back.lun.model.GameRank -import kr.lunaticbum.back.lun.model.GameRankService -import kr.lunaticbum.back.lun.model.GameType -import kr.lunaticbum.back.lun.model.UnifiedRankDto -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono - -@RestController -@RequestMapping("/api/ranks") // 모든 랭킹 API는 이 공통 경로를 사용 -class GameRankController(private val gameRankService: GameRankService) { - - /** - * [수정] 모든 게임을 위한 통합 랭킹 등록 엔드포인트 (에러 처리 추가) - */ - @PostMapping("/submit") - fun submitUnifiedRank(@RequestBody rankDto: UnifiedRankDto): Mono> { - return gameRankService.submitRank(rankDto) - .map { savedRank -> ResponseEntity.ok(savedRank) } - .onErrorResume(IllegalArgumentException::class.java) { e -> - // 서비스에서 이름 중복 예외가 발생하면 409 Conflict 상태와 에러 메시지를 반환 - Mono.just(ResponseEntity.status(HttpStatus.CONFLICT).body(e.message)) - } - .onErrorResume { - // 기타 예외는 500 Internal Server Error로 처리 - Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("랭킹 등록 중 서버 오류가 발생했습니다.")) - } - } - - /** - * 모든 게임을 위한 통합 랭킹 조회 엔드포인트 - * 예: /api/ranks/list?gameType=SUDOKU&contextId=123 - * 예: /api/ranks/list?gameType=GAME_2048 - */ - @GetMapping("/list") - fun getUnifiedRanks( - @RequestParam gameType: GameType, - @RequestParam contextId: String? = null - ): Flux { - // contextId가 "null" 문자열로 오는 경우를 방지하여 실제 null로 처리 - val effectiveContextId = if (contextId == "null") null else contextId - return gameRankService.getRanks(gameType, effectiveContextId) - } -} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/MessageController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/MessageController.kt new file mode 100644 index 0000000..7f394b0 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/MessageController.kt @@ -0,0 +1,72 @@ +package kr.lunaticbum.back.lun.controllers + +import kr.lunaticbum.back.lun.model.MessageService +import kr.lunaticbum.back.lun.model.ResultMV +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.web.bind.annotation.* +import reactor.core.publisher.Mono + +// 쪽지 전송 시 Body 데이터를 받기 위한 DTO +data class MessageRequest(val receiverId: String, val title: String, val content: String) + +@RestController +@RequestMapping("/messages") +@PreAuthorize("isAuthenticated()") // 모든 메시지 기능은 로그인한 사용자만 가능 +class MessageController(private val messageService: MessageService) { + + /** + * 안 읽은 쪽지 개수를 확인하는 API (헤더 아이콘 표시용) + */ + @GetMapping("/unread-count") + fun getUnreadCount(@AuthenticationPrincipal userDetails: UserDetails): Mono> { + return messageService.getUnreadMessageCount(userDetails.username) + .map { count -> mapOf("count" to count) } + } + + /** + * 쪽지함 페이지를 보여주는 핸들러 + */ + @GetMapping + fun getInboxPage(@AuthenticationPrincipal userDetails: UserDetails): Mono { + val vm = ResultMV("content/messages/inbox") + return messageService.getMessagesForUser(userDetails.username) + .collectList() + .map { messages -> + vm.modelMap["messages"] = messages + vm.modelMap["pageTitle"] = "내 쪽지함" + vm + } + } + + /** + * 쪽지를 보내는 API + */ + @PostMapping("/send") + fun sendMessage( + @AuthenticationPrincipal userDetails: UserDetails, + @RequestBody request: MessageRequest + ): Mono> { + return messageService.sendMessage( + userDetails.username, + request.receiverId, + request.title, + request.content + ).map { ResponseEntity.ok("메시지를 보냈습니다.") } + } + + /** + * 특정 쪽지를 읽음 처리하는 API + */ + @PostMapping("/{messageId}/read") + fun markAsRead( + @PathVariable messageId: String, + @AuthenticationPrincipal userDetails: UserDetails + ): Mono> { + return messageService.markMessageAsRead(messageId, userDetails.username) + .map { ResponseEntity.ok().build() } + .defaultIfEmpty(ResponseEntity.notFound().build()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt index fddfced..67a1d99 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt @@ -4,11 +4,14 @@ import kotlinx.coroutines.reactor.awaitSingleOrNull import kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import import org.springframework.beans.factory.annotation.Value import org.springframework.core.io.UrlResource +import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.ui.Model import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono import java.nio.file.Paths /** @@ -233,4 +236,42 @@ class PuzzleController( val vm = ResultMV("content/puzzle/upload") return vm } +} + + +@RestController +@RequestMapping("/api/ranks") // 모든 랭킹 API는 이 공통 경로를 사용 +class GameRankController(private val gameRankService: GameRankService) { + + /** + * [수정] 모든 게임을 위한 통합 랭킹 등록 엔드포인트 (에러 처리 추가) + */ + @PostMapping("/submit") + fun submitUnifiedRank(@RequestBody rankDto: UnifiedRankDto): Mono> { + return gameRankService.submitRank(rankDto) + .map { savedRank -> ResponseEntity.ok(savedRank) } + .onErrorResume(IllegalArgumentException::class.java) { e -> + // 서비스에서 이름 중복 예외가 발생하면 409 Conflict 상태와 에러 메시지를 반환 + Mono.just(ResponseEntity.status(HttpStatus.CONFLICT).body(e.message)) + } + .onErrorResume { + // 기타 예외는 500 Internal Server Error로 처리 + Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("랭킹 등록 중 서버 오류가 발생했습니다.")) + } + } + + /** + * 모든 게임을 위한 통합 랭킹 조회 엔드포인트 + * 예: /api/ranks/list?gameType=SUDOKU&contextId=123 + * 예: /api/ranks/list?gameType=GAME_2048 + */ + @GetMapping("/list") + fun getUnifiedRanks( + @RequestParam gameType: GameType, + @RequestParam contextId: String? = null + ): Flux { + // contextId가 "null" 문자열로 오는 경우를 방지하여 실제 null로 처리 + val effectiveContextId = if (contextId == "null") null else contextId + return gameRankService.getRanks(gameType, effectiveContextId) + } } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Telegram.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Telegram.kt index a93f5e6..cd17a80 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Telegram.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Telegram.kt @@ -242,78 +242,7 @@ class Telegram { } } } else if(msg.text?.startsWith("/") == true) { -// msg.text?.split(" ")?.let { cmds -> -// cmds[0].let { cmd -> -// when(cmd.trim()) { -// "/reqGapiKeys" -> { -// sendSimpleMsg(globalEvv.telegramBotKey!!,globalEvv.telegramMyId!!,"${msg.from!!.id.toString()}님이 서비스 키를 요첨항./setGaipKeys {key}") -// } -// "/setGaipKeys" -> { -// var pref = Preferences.userNodeForPackage(Telegram::class.java) -// pref.put("GAPI_KEY".plus("_").plus(msg.from!!.id.toString()), cmds[1]) -// pref.sync() -// println("test prefKey ${"GAPI_KEY".plus("_").plus(msg.from!!.id.toString())}") -// println("test prefKey ${cmds[1]}") -// println("test prefKey ${pref.get("GAPI_KEY".plus("_").plus(msg.from!!.id.toString()),"")}") -// -// } -// "/get" ->{} -// "/jf" ->{ -//// CoroutineScope(Dispatchers.IO).launch { -//// logService.log("${cmd} Start ${cmds[1]}") -//// String.format(String(Base64.getMimeDecoder().decode("aHR0cHM6Ly9qYXZtb3N0LnRvL3NlYXJjaC9tb3ZpZS8lcw==".toByteArray())),cmds[1]).getJ().let { doc -> FeedParseManager.parse(doc,rssDataService) } -//// logService.log("${cmd} END ${cmds[1]}") -//// } -//// CoroutineScope(Dispatchers.IO).launch { -//// logService.log("on Cmd JF with SO") -//// logService.log("${cmd} Start ${cmds[1]}") -//// String.format(String(Base64.getMimeDecoder().decode("aHR0cHM6Ly9rcjcwLnNvZ2lybC5zby8/cz0lcw==".toByteArray())),cmds[1]).getJ().let { doc -> FeedParseManager.parse(doc,rssDataService)} -//// logService.log("${cmd} END ${cmds[1]}") -//// } -// } -// "/lama" -> { -// val req = BumlamaReq(msg.text!!.replace(cmd,"")) -// CoroutineScope(Dispatchers.IO).launch { -// -// val fullUrl = -// "https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=lama 에게 전송 ${req.reqMsg}" -// logService.log("fullUrl >>> ${fullUrl}") -// WebClient.create().get() -// .uri(fullUrl) -// .retrieve() -// .bodyToMono(String::class.java).block() -// } -// CoroutineScope(Dispatchers.IO).launch { -// logService.log("${cmd} Start ${cmds[1]}") -//// msg.chat?.id -// try { -// val client = WebClient.create() -// client.post() -// .uri(lamaGenerated) -// .body(BodyInserters.fromValue(Gson().toJson(req))) -// .retrieve() -// .bodyToMono(String::class.java).timeout(Duration.ofSeconds(6000L)).block()?.let { result -> -// Gson().fromJson(result, BumlamaResp::class.java)?.let { sss -> -// println(Gson().toJson(sss)) -// val fullUrl = "https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${sss.response}" -// logService.log("fullUrl >>> ${fullUrl}") -// WebClient.create().get() -// .uri(fullUrl) -// .retrieve() -// .bodyToMono(String::class.java).block() ?: "FAIL" -// } -// } -// } catch (e: Exception) { -// e.printStackTrace() -// } -// -// logService.log("${cmd[0]} END ${cmd[1]}") -// } -// } -// else -> {} -// } -// } -// } + } else if (msg.text?.contains("어디") == true) { msg.from?.id?.let { sendMsg(it.toString()) } } else { @@ -341,18 +270,6 @@ class Telegram { return "Success" } - - - // fun chatClient(): ChatClient { -// return OllamaChatClient(OllamaApi("https://lama.lunaticbum.kr")) -// .withDefaultOptions( -// OllamaOptions.create() -// .withModel("phi4:14b") -// .withNumThread(5) -// .withSeed(5) -// .withTemperature(0.9f) -// ) -// } @Autowired lateinit var lama : Lama @@ -361,32 +278,6 @@ class Telegram { @GetMapping("query/{path}") fun googleQueryTest(@PathVariable path: String): String { var originalQuery = path -// POST /collections -// -// Content-Type: application/json -// -// { -// "name": "movies", -// "vector_size": 3072, -// "distance": "Cosine" -// } - -// println(lama.makeCollection()) - -// val gSearch = "https://psn.lunaticbum.kr/search?q=${originalQuery?.replace("오늘", SimpleDateFormat("yyyMMdd").format(Date()))}&language=auto&time_range=month&safesearch=0&categories=general&format=json" -// println("gSearch >>> ${gSearch}") -// var additionalInfo = StringBuffer() -// additionalInfo.append("참고자료") -// var idx = 0 -// WebClient.create().get() -// .uri(gSearch) -// .retrieve() -// .bodyToMono(SearXng::class.java).timeout(Duration.ofMinutes(20L)).block()?.let { gsResult -> -// gsResult.results?.filter { it.score > 0.5}?.forEach { -// additionalInfo.append(idx).append(":").append(Gson().toJson(it)) -// idx += 1 -// } -// } CoroutineScope(Dispatchers.IO).async { lama.generateResponse(originalQuery.replace("오늘","오늘(${SimpleDateFormat("yyyy-MM-dd").format(Date())})")) } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt index b42931c..efa1f3d 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt @@ -37,7 +37,8 @@ import java.time.format.DateTimeFormatter import java.util.* import javax.naming.AuthenticationException import kotlin.collections.emptyList - +import kr.lunaticbum.back.lun.model.Message +import reactor.core.publisher.Flux @RestController @RequestMapping("/user") @@ -47,8 +48,10 @@ class UserController( private val postManager: PostManager, private val commentService: CommentService, private val gameRankService: GameRankService, // [신규 추가] GameRankService 의존성 주입 - + private val messageService: MessageService, + private val webBookmarkService: WebBookmarkService, private val imageMetaService: ImageMetaService + ) { @@ -267,6 +270,14 @@ class UserController( val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block() vm.modelMap["myComments"] = myComments ?: emptyList() + // [신규] 받은 쪽지와 보낸 쪽지를 모두 조회하고, 시간순으로 합쳐서 모델에 추가합니다. + val receivedMessages : List = (messageService.getMessagesForUser(username).collectList().block() ?: emptyList()) as List + val sentMessages : List = (messageService.getSentMessagesByUser(username).collectList().block() ?: emptyList()) as List + + // 두 리스트를 합친 후, 최신순으로 정렬합니다. + val allMessages = (receivedMessages + sentMessages).sortedByDescending { it.timestamp } + vm.modelMap["myMessages"] = allMessages + // 4. [신규 추가] 내가 남긴 게임 랭킹 조회 (최신 20개) val myRanks = gameRankService.getRanksByPlayer(username).take(20).collectList().block() vm.modelMap["myRanks"] = myRanks ?: emptyList() @@ -282,7 +293,8 @@ class UserController( vm.modelMap["permissionRequests"] = userManager.findUsersRequestingWritePermission().collectList().block() vm.modelMap["allRecentPosts"] = postManager.findAllVersionsPaginated(PageRequest.of(0, 20)).block() // 모든 글 조회 vm.modelMap["allImages"] = imageMetaService.getAllImages().collectList().block() - + // [신규 추가] 사이트 소개글 히스토리를 모델에 추가 + vm.modelMap["aboutPostHistory"] = postManager.findAboutPostHistory().collectList().block() } return vm @@ -292,9 +304,33 @@ class UserController( @PostMapping("/request-write") @ResponseBody fun requestWrite(@AuthenticationPrincipal userDetails: UserDetails?): Mono> { - if (userDetails == null) return Mono.just(ResponseEntity.status(401).build()) + if (userDetails == null) { + return Mono.just(ResponseEntity.status(401).build()) + } + return userManager.requestWritePermission(userDetails.username) - .map { ResponseEntity.ok("요청이 완료되었습니다.") } + .map { savedUser -> // DB에 저장된 유저 정보를 받음 + // --- 텔레그램 알림 전송 로직 --- + try { + val message = "[권한 요청] 사용자 '${savedUser.user_id}'님이 글쓰기 권한을 요청했습니다." + val client = WebClient.create() + client.get() + .uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${message}") + .retrieve() + .bodyToMono(String::class.java) + .subscribe( // non-blocking (Fire-and-Forget) 방식으로 호출 + { response -> logService.log("Telegram notification sent successfully for user ${savedUser.user_id}. Response: $response") }, + { error -> logService.log("Error sending Telegram notification for user ${savedUser.user_id}: ${error.message}") } + ) + } catch (e: Exception) { + // WebClient 생성 또는 설정 중 발생할 수 있는 동기적 예외 처리 + logService.log("Exception while preparing Telegram notification for user ${savedUser.user_id}: ${e.message}") + } + // --- 알림 로직 끝 --- + + // 기존과 동일하게 클라이언트에게 성공 응답을 반환 + ResponseEntity.ok("요청이 완료되었습니다.") + } .defaultIfEmpty(ResponseEntity.status(404).body("사용자를 찾을 수 없습니다.")) } @@ -319,4 +355,77 @@ class UserController( .defaultIfEmpty(ResponseEntity.notFound().build()) } + + /** + * 북마크 저장을 위해 클라이언트로부터 받는 데이터를 담는 DTO + */ + data class BookmarkSaveRequest( + val url: String, + val title: String?, + val description: String?, + val thumbnailUrl: String?, + val userComment: String?, + val visibility: String? + ) + + @PostMapping("/bookmarks/save") + @ResponseBody + fun saveBookmark( + // [수정] DTO 대신 URL과 코멘트만 간단히 받도록 변경 + @RequestBody request: Map, + @AuthenticationPrincipal user: UserDetails? + ): Mono> { + if (user == null) { + return Mono.just(ResponseEntity.status(401).build()) + } + + val url = request["url"] ?: return Mono.just(ResponseEntity.badRequest().build()) + + // [수정] URL과 사용자 정보, PENDING 상태만으로 북마크 객체를 생성하여 저장 + val newBookmark = WebBookmark( + userId = user.username, + url = url, + userComment = request["userComment"], + visibility = request["visibility"] ?: Visibility.PRIVATE.name, + userSelectedImageUrl = request["userSelectedImageUrl"], + metadataStatus = MetadataStatus.PENDING.name // 초기 상태는 PENDING + + ) + + // DB에 저장하고 즉시 사용자에게 성공 응답을 반환 + return webBookmarkService.saveBookmark(newBookmark) + .map { savedBookmark -> ResponseEntity.ok(savedBookmark) } + } + +} + + +// --- API 요청/응답을 위한 DTO --- +data class LoginRequest(val userId: String, val userPw: String) +data class LoginResponse(val token: String) + +@RestController +@RequestMapping("/api/auth") +class AuthController( + private val authenticationManager: AuthenticationManager, + private val userManager: UserManager, + private val jwtUtil: JwtUtil, + private val logService: LogService +) { + @PostMapping("/login") + fun createAuthenticationToken(@RequestBody loginRequest: LoginRequest): ResponseEntity<*> { + // 1. 사용자 인증 + authenticationManager.authenticate( + UsernamePasswordAuthenticationToken(loginRequest.userId, loginRequest.userPw) + ) + logService.log("loginRequest.userId >>> ${loginRequest.userId}") + // 2. 인증 성공 시 UserDetails 객체 로드 + val userDetails = userManager.loadUserByUsername(loginRequest.userId) + logService.log("userDetails.username >>> ${userDetails.username}") + // 3. JWT 토큰 생성 + val token = jwtUtil.generateToken(userDetails) + + // 4. 토큰을 응답으로 반환 + return ResponseEntity.ok(LoginResponse(token)) + } } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/ArticleSaveFile.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/ArticleSaveFile.kt deleted file mode 100644 index 7334712..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/ArticleSaveFile.kt +++ /dev/null @@ -1,2 +0,0 @@ -package kr.lunaticbum.back.lun.model - diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/BumsPrivate.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/BumsPrivate.kt index bd215c5..32902fb 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/BumsPrivate.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/BumsPrivate.kt @@ -1,451 +1,28 @@ -package kr.lunaticbum.back.lun.model - -import com.google.gson.Gson -import kr.lunaticbum.back.lun.configs.GlobalEnvironment -import kr.lunaticbum.back.lun.utils.LogService -import lombok.AllArgsConstructor -import lombok.Data -import lombok.NoArgsConstructor -import org.bson.codecs.pojo.annotations.BsonIgnore -import org.jsoup.Jsoup -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.data.annotation.Id -import org.springframework.data.domain.Page -import org.springframework.data.domain.Pageable -import org.springframework.data.domain.Sort -import org.springframework.data.mongodb.core.mapping.Document -import org.springframework.data.mongodb.repository.Query -import org.springframework.data.mongodb.repository.ReactiveMongoRepository -import org.springframework.stereotype.Repository -import org.springframework.stereotype.Service -import org.springframework.web.reactive.function.client.WebClient -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import java.text.SimpleDateFormat -import java.time.Duration -import java.util.* -import org.springframework.data.domain.PageImpl -import java.time.format.DateTimeFormatter - -class BumsPrivate { -} - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Document(collection = "LocationLog") -class LocationLog { - var mFeatureName: String? = null - var mAddressLines: ArrayList = arrayListOf() - var mAdminArea: String? = null - var mSubAdminArea: String? = null - var mLocality: String? = null - var mSubLocality: String? = null - var mThoroughfare: String? = null - var mSubThoroughfare: String? = null - var mPremises: String? = null - var mPostalCode: String? = null - var mCountryCode: String? = null - var mCountryName: String? = null - var mLatitude = 0.0 - var mLongitude = 0.0 - var mPhone: String? = null - var timeString : String? = null - var mUrl: String? = null - var time : Long = 0L - var userId : String? = null - - var bettween : String? = null - - val displayTime: String - get() { - // 1. timeString 값이 존재하고 비어있지 않으면, 그 값을 사용한다. - if (!this.timeString.isNullOrBlank()) { - return this.timeString!! - } - - // 2. timeString이 없을 경우, 원본 logTime 객체가 있다면 포맷팅해서 반환한다. - if (this.time != null) { - // 원하는 날짜/시간 포맷 정의 - val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") - return formatter.format(Date(this.time)) - } - - // 3. 둘 다 없으면 "시간 없음"을 반환한다. - return "[시간 정보 없음]" - } - - override fun toString(): String { - val buffer = StringBuffer() - buffer.append(mFeatureName).append("|").append("\n") - buffer.append(mAddressLines.joinToString(" , ")).append("|").append("\n") - buffer.append(mAdminArea).append("|").append("\n") - buffer.append(mSubAdminArea).append("|").append("\n") - buffer.append(mLocality).append("|").append("\n") - buffer.append(mSubLocality).append("|").append("\n") - buffer.append(mThoroughfare).append("|").append("\n") - buffer.append(mSubThoroughfare).append("|").append("\n") - buffer.append(mPremises).append("|").append("\n") - buffer.append(mPostalCode).append("|").append("\n") - buffer.append(mCountryCode).append("|").append("\n") - buffer.append(mCountryName).append("|").append("\n") - buffer.append(mLatitude).append("|").append("\n") - buffer.append(mLongitude).append("|").append("\n") - buffer.append(mPhone).append("|").append("\n") - buffer.append(mUrl).append("|").append("\n") - return buffer.toString() - } -} - -@Repository -interface LocationLogRepository : ReactiveMongoRepository { - @Query("{ 'time' : { \$gte: ?0 } }") - fun findRecent(since: Long, sort: Sort): Flux - -// @Query("SELECT l FROM LocationLog l WHERE l.timeString >= :since ORDER BY l.timeString DESC") -// fun findRecent(@Param("since") since: String): Flux - - fun findTop30ByOrderByTimeDesc(): Flux - fun findAllBy() : Mono - fun findFirstByOrderByTimeDesc() : Mono - fun findFirstByUserIdOrderByTimeDesc(userId: String) : Mono - fun save(log: LocationLog): Mono -} -interface LocationService { - -} - -@Service -class LocationLogService : LocationService { - @Autowired - private lateinit var logService: LogService - - @Autowired - private lateinit var logRepository: LocationLogRepository - - fun findAll(pageable: Pageable): Page { - - // 1. 페이지 데이터 가져오기 (비동기 -> 동기 'block()') - // Flux 스트림에 정렬, 스킵, 제한을 적용한 뒤 List로 변환합니다. - val items: List = logRepository - .findAll(pageable.getSort()) - .skip(pageable.getOffset()) - .take(pageable.getPageSize().toLong()) - .collectList() // Flux를 Mono>로 변환 - .block() ?: emptyList() // Mono를 block()하여 실제 List를 추출 - - // 2. 전체 카운트 가져오기 (페이지네이션 계산을 위해 별도 쿼리 필요) - val totalCount: Long = logRepository - .count() // Flux (count) - .block() ?: 0L // Mono를 block()하여 실제 Long 값을 추출 - - // 3. Page 구현체(PageImpl)로 조합하여 반환 - return PageImpl(items, pageable, totalCount) - } - - fun find10() : List { - val sinceMills = System.currentTimeMillis() - ((24 * 60 * 60 * 1000) * 100) - println("sinceMills >> $sinceMills") - val sort = Sort.by(Sort.Direction.DESC, "time") // 오름차순 정렬 -// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") -// val since = LocalDateTime.now().minusHours(24).format(formatter) -// println("since >> $since") - val flux = filterByDistanceReactive(logRepository.findRecent(sinceMills,sort), 10.0) - return flux.collectList().block(Duration.ofSeconds(30)) ?: listOf() - } - - fun getLocationLog() : LocationLog? { - return logRepository.findFirstByOrderByTimeDesc().block() - } - - fun getLocationLogBy(userId : String) : LocationLog? { - return logRepository.findFirstByOrderByTimeDesc().block() - } - fun filterByDistanceReactive(flux: Flux, minDistanceMeter: Double): Flux { - return flux - .buffer(2, 1) - .filter { pair -> - if (pair.size < 2) true - else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude) >= minDistanceMeter - } - .map { pair -> - val distance = if (pair.size < 2) 0.0 else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude) - val base = pair[0] - println("base >>> ${base.time} ${base.timeString}") - base.bettween = String.format("%.2f m", distance) // 소수점 두자리까지 거리 표시 - base - } - } - - // Haversine 거리계산 함수 (단위:m) - fun haversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { - val R = 6371000.0 // 지구 반지름(m) - val dLat = Math.toRadians(lat2 - lat1) - val dLon = Math.toRadians(lon2 - lon1) - val a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * - Math.sin(dLon / 2) * Math.sin(dLon / 2) - val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) - return R * c - } - - fun save(log: LocationLog) { - println("saved msg before ${log}") - logRepository.save(log).subscribe( { println("saved msg after ${it}") },{e -> e.printStackTrace()},{ - println("saved msg comp") - }) - } -} - -interface RssDataInterface { - fun title() : String - fun thumbnailUrl() : String - fun originPage() : String - fun description() : String - fun pubDate() : Long - fun category() : RssDataType - fun getCho() : String? - -} -enum class RssDataType { - NO_DATA, - YOUTUBE, - NewsFeed, - GURU, - Most, - TAGS, - REDDIT, - REDDIT_nsfw, - Dotax, - FmKorae, - DcInside, - RuliWeb, - Clien, - TheQoo, - Arca; - -// fun getResId() = when (this) { -// YOUTUBE -> R.drawable.youtube -// REDDIT, REDDIT_nsfw -> R.drawable.reddit -// Dotax -> R.drawable.daum -// FmKorae -> R.drawable.fmk -// DcInside -> R.drawable.dcinside -// Arca -> R.drawable.arca -// else -> { -// 0 -// } -// } - - fun defaultImgSize() = when (this) { - YOUTUBE -> 200 - REDDIT_nsfw,GURU,Most -> 360 - else -> { 120 } - } - -// fun getDefaultVisibiliy() = when (this) { -// REDDIT_nsfw,GURU,Most,NewsFeed -> View.GONE -// else -> { View.VISIBLE } -// } -} -@Data -@NoArgsConstructor -@AllArgsConstructor -@Document(collection = "RssData") -class RssData : RssDataInterface { - - @Id - var originPage : String? = null - var title : String? = null - var description : String? = null - var thumbnail : String? = null - var pubDate : Long = 0L - var category : String? = null - - var chosung : String? = null - - - @BsonIgnore - var mRssDataType : RssDataType? = null - override fun title(): String { - return when(category()){ - RssDataType.NewsFeed -> { - if(title?.length ?: 0 > 30) title?.substring(0,30).plus("...") else title ?: "" - } - else -> title ?: "" - }.apply { -// chosung = JamoUtils.split(this).joinToString("") - } - } - - override fun thumbnailUrl(): String { - return thumbnail ?: "" - } - - override fun originPage(): String { - return originPage ?: "" - } - - override fun description(): String { - - return when(category()){ - RssDataType.YOUTUBE -> { - if(description?.contains("게시자") == true) description!!.split("게시자")[0] else description ?: "" - } - RssDataType.NewsFeed -> { - category().name - } - else -> description.plus(" / ").plus(category().name) - } - } - - override fun pubDate(): Long { - return pubDate - } - - override fun category(): RssDataType { - if (mRssDataType == null) - mRssDataType = RssDataType.valueOf(category!!) - return mRssDataType!! - } - - override fun getCho(): String? { - return chosung - } -} - - -val USAGT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15" -fun String.getJ() = Jsoup.connect(this).userAgent(USAGT).get() -object FeedParseManager { - val parsers = listOf(QVZTb2dpcmw,SkFWTW9zdA) - fun parse(doc : org.jsoup.nodes.Document, service: RssDataService) { - try { - parsers.filter { doc.title().contains(it.getName()) }.first()?.let { - it.parse(doc,service) - } - } catch (e : Exception) { - - e.printStackTrace() - } - } -} -interface SoInterface{ - fun getName() : String - fun parse(doc : org.jsoup.nodes.Document,service: RssDataService) -} -object QVZTb2dpcmw : SoInterface { - override fun getName(): String { - return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray())) - } - override fun parse(doc : org.jsoup.nodes.Document, service : RssDataService) { - var lists = arrayListOf() - doc.getElementsByTag("article").forEach { article -> - - val title = article.getElementsByTag("a").get(0).attr("title") - val href = article.getElementsByTag("a").get(0).attr("href") - val img = article.getElementsByTag("img").get(0).attr("data-src") - service.save(RssData().apply { - this.originPage = href - this.title = title - this.description = "Sogirl" - this.thumbnail = img - this.pubDate = Date().time - this.category = RssDataType.GURU.name - - }) { -// CoroutineScope(Dispatchers.IO).launch { -// service.sendMsg("${title}\n${img}\n${href}") -// } - } - } -// lists.map { -// service.sendMsg("${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}") -// } - } -} - -object SkFWTW9zdA : SoInterface { - var dmy = SimpleDateFormat("dd-MM-yyyy") - override fun getName(): String { - return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray())) - } - override fun parse(doc: org.jsoup.nodes.Document, service: RssDataService) { - var lists = arrayListOf() - doc.getElementsByClass("card").forEach { card -> - var thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("src") else "" - if (thumb.contains("No+Poster")) thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("data-src") else thumb - var model = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("alt") else "" - if(card.getElementsByClass("card-block").size > 0) if(card.getElementsByClass("card-block").size > 0) { - val link = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("href") - val title = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("title") - val date = card.getElementsByTag("span").get(0).text() - service.save(RssData().apply { - lists.add(this) - description = model - thumbnail = thumb - originPage = link - this.title = title - category = RssDataType.Most.name - try { - pubDate = dmy.parse(date).time - }catch (e : Exception) {e.printStackTrace()} - }){ -// CoroutineScope(Dispatchers.IO).launch { -// service.sendMsg("${title}\n${thumb}\n${link}") -// } - } - } - } -// service.sendMsg(lists.map { -// "${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}\n" -// }.joinToString(" \n ")) - } -} -@Repository -interface RssDataRepository : ReactiveMongoRepository { - fun findFirstByOriginPageEquals(originPage : String): Mono - fun findAllByOrderByPubDate() : Mono> - fun save(log: RssData): Mono -} - -@Service -class RssDataService { - @Autowired - private lateinit var logService: LogService - - @Autowired - private lateinit var rssDataRepository: RssDataRepository - fun hasItem(originPage : String) { - - } - fun getLocationLog() : List? { - return rssDataRepository.findAllByOrderByPubDate().block() - } - - - fun save(log: RssData, callback : (Boolean)->Unit) { - println("saved msg before ${Gson().toJson(log)}") - log.originPage?.let { - if(rssDataRepository.findFirstByOriginPageEquals(it).block() == null) { - rssDataRepository.save(log) - .subscribe({ println("saved msg after ${it}") }, { e -> e.printStackTrace() }, { - println("saved msg comp") - callback(true) - }) - } else { - println("있어???") - } - } - } - - @Autowired - lateinit var globalEvv : GlobalEnvironment - - suspend fun sendMsg(data : String) { - val client = WebClient.create() - client.get() - .uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${data}") - .retrieve() - .bodyToMono(String::class.java).block() ?: "FAIL" - } -} \ No newline at end of file +//package kr.lunaticbum.back.lun.model +// +//import com.google.gson.Gson +//import kr.lunaticbum.back.lun.configs.GlobalEnvironment +//import kr.lunaticbum.back.lun.utils.LogService +//import lombok.AllArgsConstructor +//import lombok.Data +//import lombok.NoArgsConstructor +//import org.bson.codecs.pojo.annotations.BsonIgnore +//import org.jsoup.Jsoup +//import org.springframework.beans.factory.annotation.Autowired +//import org.springframework.data.annotation.Id +//import org.springframework.data.domain.Page +//import org.springframework.data.domain.Pageable +//import org.springframework.data.domain.Sort +//import org.springframework.data.mongodb.core.mapping.Document +//import org.springframework.data.mongodb.repository.Query +//import org.springframework.data.mongodb.repository.ReactiveMongoRepository +//import org.springframework.stereotype.Repository +//import org.springframework.stereotype.Service +//import org.springframework.web.reactive.function.client.WebClient +//import reactor.core.publisher.Flux +//import reactor.core.publisher.Mono +//import java.text.SimpleDateFormat +//import java.time.Duration +//import java.util.* +//import org.springframework.data.domain.PageImpl +//import java.time.format.DateTimeFormatter diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt index 30933f0..ed933ba 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt @@ -11,140 +11,140 @@ import org.springframework.stereotype.Repository import org.springframework.stereotype.Service import reactor.core.publisher.Flux import reactor.core.publisher.Mono - -/** - * 모든 게임의 랭킹을 저장하는 통합 모델 - */ -@Document(collection = "game_ranks") -data class GameRank( - @Id - val id: String? = null, - val gameType: GameType, // 게임 종류 (2048, SUDOKU, SPIDER 등) - val contextId: String?, // 게임의 세부 ID (예: 스도쿠 퍼즐 Key, 스파이더 난이도, 노노그램 퍼즐 ID) - val playerName: String, // 표준화된 플레이어 이름 필드 - - /** * 기본 점수 필드 (정렬 1순위). - * - 2048: 점수 (높을수록 좋음) - * - Sudoku: 완료 시간(초) (낮을수록 좋음) - * - Spider: 이동 횟수 (낮을수록 좋음) - */ - val primaryScore: Long, - - /** * 보조 점수 필드 (정렬 2순위. 예: 스파이더의 완료 시간). - */ - val secondaryScore: Long? = null, - - val timestamp: Instant = Instant.now() -) - -/** - * 지원하는 게임 타입을 정의하는 Enum - */ -enum class GameType { - GAME_2048, - SUDOKU, - SPIDER, - NONOGRAM -} - -/** - * 랭킹 등록 시 모든 프론트엔드에서 공통으로 사용할 DTO - */ -data class UnifiedRankDto( - val gameType: GameType, - val contextId: String?, - val playerName: String, - val primaryScore: Long, - val secondaryScore: Long? = null -) - -@Repository -interface GameRankRepository : ReactiveSortingRepository { - - fun save(gameRank: GameRank): Mono - // 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048) - fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc( - gameType: GameType, - contextId: String? - ): Flux - - // 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수) - fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc( - gameType: GameType, - contextId: String? - ): Flux - - // [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회 - fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux -} - - -@Service -class GameRankService( - private val rankRepository: GameRankRepository, - private val userManager: UserManager ) { - /** - * 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다. - */ - fun getRanks(gameType: GameType, contextId: String?): Flux { - return when (gameType) { - // 점수가 높아야 하는 게임 (2048) - GameType.GAME_2048 -> - rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(gameType, contextId) - - // 점수가 낮아야 하는 게임 (스도쿠 시간, 스파이더 무브/시간, 노노그램 시간) - GameType.SUDOKU, GameType.SPIDER, GameType.NONOGRAM -> - rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(gameType, contextId) - } - } - - /** - * [수정] 공통 DTO를 받아 랭킹을 저장 (사용자 이름 중복 체크 로직 추가) - */ - fun submitRank(rankDto: UnifiedRankDto): Mono { - val auth = SecurityContextHolder.getContext().authentication - - // 로그인 사용자인지, 비로그인(익명) 사용자인지 확인 - val isAuthenticated = auth != null && auth.isAuthenticated && auth !is AnonymousAuthenticationToken - - if (isAuthenticated) { - // 로그인 사용자: DTO의 playerName을 실제 로그인한 사용자의 ID로 강제 설정 (보안 강화) - val principal = auth.principal as UserDetails - val authenticatedUsername = principal.username - - val gameRank = GameRank( - gameType = rankDto.gameType, - contextId = rankDto.contextId, - playerName = authenticatedUsername, // 실제 인증된 이름 사용 - primaryScore = rankDto.primaryScore, - secondaryScore = rankDto.secondaryScore - ) - return rankRepository.save(gameRank) - } else { - // 비로그인 사용자: 입력한 이름이 기존 회원 ID와 중복되는지 확인 - return userManager.findById(rankDto.playerName) - .flatMap { existingUser -> - // 사용자가 존재하면 에러 발생 - Mono.error(IllegalArgumentException("이미 등록된 회원의 이름입니다. 다른 이름을 사용해주세요.")) - } - .switchIfEmpty(Mono.defer { - // 사용자가 존재하지 않으면 랭킹 저장 진행 - val gameRank = GameRank( - gameType = rankDto.gameType, - contextId = rankDto.contextId, - playerName = rankDto.playerName, - primaryScore = rankDto.primaryScore, - secondaryScore = rankDto.secondaryScore - ) - rankRepository.save(gameRank) - }) - } - } - - /** - * [신규 추가] 특정 플레이어의 모든 게임 랭킹을 조회합니다. - */ - fun getRanksByPlayer(playerName: String): Flux { - return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName) - } -} \ No newline at end of file +// +///** +// * 모든 게임의 랭킹을 저장하는 통합 모델 +// */ +//@Document(collection = "game_ranks") +//data class GameRank( +// @Id +// val id: String? = null, +// val gameType: GameType, // 게임 종류 (2048, SUDOKU, SPIDER 등) +// val contextId: String?, // 게임의 세부 ID (예: 스도쿠 퍼즐 Key, 스파이더 난이도, 노노그램 퍼즐 ID) +// val playerName: String, // 표준화된 플레이어 이름 필드 +// +// /** * 기본 점수 필드 (정렬 1순위). +// * - 2048: 점수 (높을수록 좋음) +// * - Sudoku: 완료 시간(초) (낮을수록 좋음) +// * - Spider: 이동 횟수 (낮을수록 좋음) +// */ +// val primaryScore: Long, +// +// /** * 보조 점수 필드 (정렬 2순위. 예: 스파이더의 완료 시간). +// */ +// val secondaryScore: Long? = null, +// +// val timestamp: Instant = Instant.now() +//) +// +///** +// * 지원하는 게임 타입을 정의하는 Enum +// */ +//enum class GameType { +// GAME_2048, +// SUDOKU, +// SPIDER, +// NONOGRAM +//} +// +///** +// * 랭킹 등록 시 모든 프론트엔드에서 공통으로 사용할 DTO +// */ +//data class UnifiedRankDto( +// val gameType: GameType, +// val contextId: String?, +// val playerName: String, +// val primaryScore: Long, +// val secondaryScore: Long? = null +//) +// +//@Repository +//interface GameRankRepository : ReactiveSortingRepository { +// +// fun save(gameRank: GameRank): Mono +// // 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048) +// fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc( +// gameType: GameType, +// contextId: String? +// ): Flux +// +// // 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수) +// fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc( +// gameType: GameType, +// contextId: String? +// ): Flux +// +// // [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회 +// fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux +//} +// +// +//@Service +//class GameRankService( +// private val rankRepository: GameRankRepository, +// private val userManager: UserManager ) { +// /** +// * 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다. +// */ +// fun getRanks(gameType: GameType, contextId: String?): Flux { +// return when (gameType) { +// // 점수가 높아야 하는 게임 (2048) +// GameType.GAME_2048 -> +// rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(gameType, contextId) +// +// // 점수가 낮아야 하는 게임 (스도쿠 시간, 스파이더 무브/시간, 노노그램 시간) +// GameType.SUDOKU, GameType.SPIDER, GameType.NONOGRAM -> +// rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(gameType, contextId) +// } +// } +// +// /** +// * [수정] 공통 DTO를 받아 랭킹을 저장 (사용자 이름 중복 체크 로직 추가) +// */ +// fun submitRank(rankDto: UnifiedRankDto): Mono { +// val auth = SecurityContextHolder.getContext().authentication +// +// // 로그인 사용자인지, 비로그인(익명) 사용자인지 확인 +// val isAuthenticated = auth != null && auth.isAuthenticated && auth !is AnonymousAuthenticationToken +// +// if (isAuthenticated) { +// // 로그인 사용자: DTO의 playerName을 실제 로그인한 사용자의 ID로 강제 설정 (보안 강화) +// val principal = auth.principal as UserDetails +// val authenticatedUsername = principal.username +// +// val gameRank = GameRank( +// gameType = rankDto.gameType, +// contextId = rankDto.contextId, +// playerName = authenticatedUsername, // 실제 인증된 이름 사용 +// primaryScore = rankDto.primaryScore, +// secondaryScore = rankDto.secondaryScore +// ) +// return rankRepository.save(gameRank) +// } else { +// // 비로그인 사용자: 입력한 이름이 기존 회원 ID와 중복되는지 확인 +// return userManager.findById(rankDto.playerName) +// .flatMap { existingUser -> +// // 사용자가 존재하면 에러 발생 +// Mono.error(IllegalArgumentException("이미 등록된 회원의 이름입니다. 다른 이름을 사용해주세요.")) +// } +// .switchIfEmpty(Mono.defer { +// // 사용자가 존재하지 않으면 랭킹 저장 진행 +// val gameRank = GameRank( +// gameType = rankDto.gameType, +// contextId = rankDto.contextId, +// playerName = rankDto.playerName, +// primaryScore = rankDto.primaryScore, +// secondaryScore = rankDto.secondaryScore +// ) +// rankRepository.save(gameRank) +// }) +// } +// } +// +// /** +// * [신규 추가] 특정 플레이어의 모든 게임 랭킹을 조회합니다. +// */ +// fun getRanksByPlayer(playerName: String): Flux { +// return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName) +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt index a9ed559..01127a7 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt @@ -72,7 +72,8 @@ interface ImageMetaRepository : ReactiveMongoRepository { class ImageMetaService( private val repository: ImageMetaRepository, private val logService: LogService, // LogService 주입 - @Value("\${image.upload.path}") private val uploadPath: String // application.properties의 업로드 경로 주입 + @Value("\${image.upload.path}") private val uploadPath: String, // application.properties의 업로드 경로 주입 + @Value("\${build.config.run}") private val build_config_run: String ) { // [신규 추가] 백그라운드 작업용 Coroutine Scope 정의 @@ -95,14 +96,17 @@ class ImageMetaService( return repository.findRandomImage() } + // application.properties의 업로드 경로 주입 /** * [신규 추가] Spring Boot가 준비되었을 때(부팅 완료) 실행되는 리스너 */ @Profile("!local") @EventListener(ApplicationReadyEvent::class) fun onApplicationReady() { - logService.log("Application ready. Launching initial image DB sync task...") - launchSyncTask() + logService.log("Application ${build_config_run} ready. Launching initial image DB sync task...") + if (build_config_run.contains("prd")) { + launchSyncTask() + } } /** diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Message.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Message.kt new file mode 100644 index 0000000..3fbb5ca --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Message.kt @@ -0,0 +1,70 @@ +package kr.lunaticbum.back.lun.model + +import org.bson.BsonType +import org.bson.codecs.pojo.annotations.BsonId +import org.bson.codecs.pojo.annotations.BsonRepresentation +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document +import org.springframework.data.mongodb.repository.ReactiveMongoRepository +import org.springframework.stereotype.Repository +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +@Document(collection = "Messages") +data class Message( + @Id + @BsonId + @BsonRepresentation(BsonType.OBJECT_ID) + var id: String? = null, + + var senderId: String, + var receiverId: String, + var title: String, + var content: String, + var timestamp: Long = System.currentTimeMillis(), + var isRead: Boolean = false +) + +@Repository +interface MessageRepository : ReactiveMongoRepository { + fun findByReceiverIdOrderByTimestampDesc(receiverId: String): Flux + fun countByReceiverIdAndIsRead(receiverId: String, isRead: Boolean): Mono + + fun findBySenderIdOrderByTimestampDesc(senderId: String): Flux +} + +@Service +class MessageService(private val messageRepository: MessageRepository) { + + fun getMessagesForUser(userId: String): Flux { + return messageRepository.findByReceiverIdOrderByTimestampDesc(userId) + } + + fun getUnreadMessageCount(userId: String): Mono { + return messageRepository.countByReceiverIdAndIsRead(userId, false) + } + + fun sendMessage(senderId: String, receiverId: String, title: String, content: String): Mono { + val message = Message(senderId = senderId, receiverId = receiverId, title = title, content = content) + return messageRepository.save(message) + } + + fun markMessageAsRead(messageId: String, userId: String): Mono { + return messageRepository.findById(messageId) + .filter { it.receiverId == userId } // 본인 쪽지만 읽음 처리하도록 보안 강화 + .flatMap { message -> + if (!message.isRead) { + message.isRead = true + messageRepository.save(message) + } else { + Mono.just(message) + } + } + } + + // [신규] 사용자가 보낸 쪽지를 가져오는 서비스를 추가합니다. + fun getSentMessagesByUser(userId: String): Flux { + return messageRepository.findBySenderIdOrderByTimestampDesc(userId) + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt index 105eaa3..f47e818 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt @@ -1,6 +1,7 @@ package kr.lunaticbum.back.lun.model import com.fasterxml.jackson.databind.ObjectMapper +import com.google.gson.Gson import kr.lunaticbum.back.lun.configs.GlobalEnvironment import kr.lunaticbum.back.lun.utils.LogService import lombok.AllArgsConstructor @@ -10,17 +11,21 @@ import lombok.NoArgsConstructor import okio.Timeout import org.bson.BsonType import org.bson.codecs.pojo.annotations.BsonId +import org.bson.codecs.pojo.annotations.BsonIgnore import org.bson.codecs.pojo.annotations.BsonRepresentation +import org.jsoup.Jsoup import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.annotation.Id +import org.springframework.data.domain.Page import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort import org.springframework.data.mongodb.core.FindAndModifyOptions // [추가됨] import org.springframework.data.mongodb.core.ReactiveMongoTemplate import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.core.query.Criteria -import org.springframework.data.mongodb.core.query.Query + import org.springframework.data.mongodb.core.query.Update import org.springframework.data.mongodb.repository.Aggregation import org.springframework.data.mongodb.repository.ReactiveMongoRepository @@ -35,10 +40,19 @@ import java.time.Duration import org.springframework.data.mongodb.core.index.CompoundIndex // [신규 추가] import org.springframework.data.mongodb.core.index.IndexDirection // [신규 추가] import org.springframework.data.mongodb.core.index.Indexed // [신규 추가] +import org.springframework.data.mongodb.core.query.Query +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.web.reactive.function.client.WebClient import java.text.SimpleDateFormat +import java.util.ArrayList import java.util.Base64 import java.util.Date +enum class PostType { + STANDARD, // 일반 블로그 글 + ABOUT_SITE // 사이트 소개 글 +} + @Document(collection = "Post") @CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}") data class Post( @@ -74,7 +88,9 @@ data class Post( var readCount : Long = 0, var voteCount : Long = 0, var unlikeCount : Long = 0, - var isBlocked: Boolean = false + var isBlocked: Boolean = false, + // [추가] 게시물 타입을 구분하는 필드. 기본값은 'STANDARD' + var postType: String = PostType.STANDARD.name ) @Document(collection = "Comment") @@ -157,7 +173,7 @@ interface PostRepository : ReactiveMongoRepository { fun countByOrderByModifyTimeDesc(): Mono fun findTop5ByOrderByReadCountDesc(): Flux fun findTop5ByOrderByModifyTimeDesc(): Flux - + fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux // [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상) @Aggregation(pipeline = [ "{ \$match: { posting: true, isBlocked: false } }", // [수정됨] @@ -264,6 +280,18 @@ class PostManager( @Autowired private lateinit var bCryptPasswordEncoder: PasswordEncoder + // [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드 + fun findLatestAboutPost(): Mono { + // 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴 + return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name) + .next() // Flux에서 첫 번째 아이템(Mono)을 반환 + } + + // [신규 추가] '사이트 소개' 글의 모든 버전(히스토리)을 찾는 메소드 + fun findAboutPostHistory(): Flux { + return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name) + } + // [신규] 게시물 차단 fun blockPost(postId: String): Mono { return postRepository.findById(postId).flatMap { post -> @@ -589,3 +617,543 @@ object PayloadDecoder { return objectMapper.readValue(originalJson, clazz) } } + + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "LocationLog") +class LocationLog { + var mFeatureName: String? = null + var mAddressLines: ArrayList = arrayListOf() + var mAdminArea: String? = null + var mSubAdminArea: String? = null + var mLocality: String? = null + var mSubLocality: String? = null + var mThoroughfare: String? = null + var mSubThoroughfare: String? = null + var mPremises: String? = null + var mPostalCode: String? = null + var mCountryCode: String? = null + var mCountryName: String? = null + var mLatitude = 0.0 + var mLongitude = 0.0 + var mPhone: String? = null + var timeString : String? = null + var mUrl: String? = null + var time : Long = 0L + var userId : String? = null + + var bettween : String? = null + + val displayTime: String + get() { + // 1. timeString 값이 존재하고 비어있지 않으면, 그 값을 사용한다. + if (!this.timeString.isNullOrBlank()) { + return this.timeString!! + } + + // 2. timeString이 없을 경우, 원본 logTime 객체가 있다면 포맷팅해서 반환한다. + if (this.time != null) { + // 원하는 날짜/시간 포맷 정의 + val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") + return formatter.format(Date(this.time)) + } + + // 3. 둘 다 없으면 "시간 없음"을 반환한다. + return "[시간 정보 없음]" + } + + override fun toString(): String { + val buffer = StringBuffer() + buffer.append(mFeatureName).append("|").append("\n") + buffer.append(mAddressLines.joinToString(" , ")).append("|").append("\n") + buffer.append(mAdminArea).append("|").append("\n") + buffer.append(mSubAdminArea).append("|").append("\n") + buffer.append(mLocality).append("|").append("\n") + buffer.append(mSubLocality).append("|").append("\n") + buffer.append(mThoroughfare).append("|").append("\n") + buffer.append(mSubThoroughfare).append("|").append("\n") + buffer.append(mPremises).append("|").append("\n") + buffer.append(mPostalCode).append("|").append("\n") + buffer.append(mCountryCode).append("|").append("\n") + buffer.append(mCountryName).append("|").append("\n") + buffer.append(mLatitude).append("|").append("\n") + buffer.append(mLongitude).append("|").append("\n") + buffer.append(mPhone).append("|").append("\n") + buffer.append(mUrl).append("|").append("\n") + return buffer.toString() + } +} + +@Repository +interface LocationLogRepository : ReactiveMongoRepository { + @Aggregation(pipeline = [ + "{ \$match: { 'time' : { \$gte: ?0 } } }" + ]) + fun findRecent(since: Long, sort: Sort): Flux + +// @Query("SELECT l FROM LocationLog l WHERE l.timeString >= :since ORDER BY l.timeString DESC") +// fun findRecent(@Param("since") since: String): Flux + + fun findTop30ByOrderByTimeDesc(): Flux + fun findAllBy() : Mono + fun findFirstByOrderByTimeDesc() : Mono + fun findFirstByUserIdOrderByTimeDesc(userId: String) : Mono + fun save(log: LocationLog): Mono +} +interface LocationService { + +} + +@Service +class LocationLogService : LocationService { + @Autowired + private lateinit var logService: LogService + + @Autowired + private lateinit var logRepository: LocationLogRepository + + fun findAll(pageable: Pageable): Page { + + // 1. 페이지 데이터 가져오기 (비동기 -> 동기 'block()') + // Flux 스트림에 정렬, 스킵, 제한을 적용한 뒤 List로 변환합니다. + val items: List = logRepository + .findAll(pageable.getSort()) + .skip(pageable.getOffset()) + .take(pageable.getPageSize().toLong()) + .collectList() // Flux를 Mono>로 변환 + .block() ?: emptyList() // Mono를 block()하여 실제 List를 추출 + + // 2. 전체 카운트 가져오기 (페이지네이션 계산을 위해 별도 쿼리 필요) + val totalCount: Long = logRepository + .count() // Flux (count) + .block() ?: 0L // Mono를 block()하여 실제 Long 값을 추출 + + // 3. Page 구현체(PageImpl)로 조합하여 반환 + return PageImpl(items, pageable, totalCount) + } + + fun find10() : List { + val sinceMills = System.currentTimeMillis() - ((24 * 60 * 60 * 1000) * 100) + println("sinceMills >> $sinceMills") + val sort = Sort.by(Sort.Direction.DESC, "time") // 오름차순 정렬 +// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") +// val since = LocalDateTime.now().minusHours(24).format(formatter) +// println("since >> $since") + val flux = filterByDistanceReactive(logRepository.findRecent(sinceMills,sort), 10.0) + return flux.collectList().block(Duration.ofSeconds(30)) ?: listOf() + } + + fun getLocationLog() : LocationLog? { + return logRepository.findFirstByOrderByTimeDesc().block() + } + + fun getLocationLogBy(userId : String) : LocationLog? { + return logRepository.findFirstByOrderByTimeDesc().block() + } + fun filterByDistanceReactive(flux: Flux, minDistanceMeter: Double): Flux { + return flux + .buffer(2, 1) + .filter { pair -> + if (pair.size < 2) true + else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude) >= minDistanceMeter + } + .map { pair -> + val distance = if (pair.size < 2) 0.0 else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude) + val base = pair[0] + println("base >>> ${base.time} ${base.timeString}") + base.bettween = String.format("%.2f m", distance) // 소수점 두자리까지 거리 표시 + base + } + } + + // Haversine 거리계산 함수 (단위:m) + fun haversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val R = 6371000.0 // 지구 반지름(m) + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2) + val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + return R * c + } + + fun save(log: LocationLog) { + println("saved msg before ${log}") + logRepository.save(log).subscribe( { println("saved msg after ${it}") },{e -> e.printStackTrace()},{ + println("saved msg comp") + }) + } +} + +interface RssDataInterface { + fun title() : String + fun thumbnailUrl() : String + fun originPage() : String + fun description() : String + fun pubDate() : Long + fun category() : RssDataType + fun getCho() : String? + +} +enum class RssDataType { + NO_DATA, + YOUTUBE, + NewsFeed, + GURU, + Most, + TAGS, + REDDIT, + REDDIT_nsfw, + Dotax, + FmKorae, + DcInside, + RuliWeb, + Clien, + TheQoo, + Arca; + +// fun getResId() = when (this) { +// YOUTUBE -> R.drawable.youtube +// REDDIT, REDDIT_nsfw -> R.drawable.reddit +// Dotax -> R.drawable.daum +// FmKorae -> R.drawable.fmk +// DcInside -> R.drawable.dcinside +// Arca -> R.drawable.arca +// else -> { +// 0 +// } +// } + + fun defaultImgSize() = when (this) { + YOUTUBE -> 200 + REDDIT_nsfw,GURU,Most -> 360 + else -> { 120 } + } + +// fun getDefaultVisibiliy() = when (this) { +// REDDIT_nsfw,GURU,Most,NewsFeed -> View.GONE +// else -> { View.VISIBLE } +// } +} +@Data +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "RssData") +class RssData : RssDataInterface { + + @Id + var originPage : String? = null + var title : String? = null + var description : String? = null + var thumbnail : String? = null + var pubDate : Long = 0L + var category : String? = null + + var chosung : String? = null + + + @BsonIgnore + var mRssDataType : RssDataType? = null + override fun title(): String { + return when(category()){ + RssDataType.NewsFeed -> { + if(title?.length ?: 0 > 30) title?.substring(0,30).plus("...") else title ?: "" + } + else -> title ?: "" + }.apply { +// chosung = JamoUtils.split(this).joinToString("") + } + } + + override fun thumbnailUrl(): String { + return thumbnail ?: "" + } + + override fun originPage(): String { + return originPage ?: "" + } + + override fun description(): String { + + return when(category()){ + RssDataType.YOUTUBE -> { + if(description?.contains("게시자") == true) description!!.split("게시자")[0] else description ?: "" + } + RssDataType.NewsFeed -> { + category().name + } + else -> description.plus(" / ").plus(category().name) + } + } + + override fun pubDate(): Long { + return pubDate + } + + override fun category(): RssDataType { + if (mRssDataType == null) + mRssDataType = RssDataType.valueOf(category!!) + return mRssDataType!! + } + + override fun getCho(): String? { + return chosung + } +} + + +val USAGT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15" +fun String.getJ() = Jsoup.connect(this).userAgent(USAGT).get() +object FeedParseManager { + val parsers = listOf(QVZTb2dpcmw,SkFWTW9zdA) + fun parse(doc : org.jsoup.nodes.Document, service: RssDataService) { + try { + parsers.filter { doc.title().contains(it.getName()) }.first()?.let { + it.parse(doc,service) + } + } catch (e : Exception) { + + e.printStackTrace() + } + } +} +interface SoInterface{ + fun getName() : String + fun parse(doc : org.jsoup.nodes.Document,service: RssDataService) +} +object QVZTb2dpcmw : SoInterface { + override fun getName(): String { + return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray())) + } + override fun parse(doc : org.jsoup.nodes.Document, service : RssDataService) { + var lists = arrayListOf() + doc.getElementsByTag("article").forEach { article -> + + val title = article.getElementsByTag("a").get(0).attr("title") + val href = article.getElementsByTag("a").get(0).attr("href") + val img = article.getElementsByTag("img").get(0).attr("data-src") + service.save(RssData().apply { + this.originPage = href + this.title = title + this.description = "Sogirl" + this.thumbnail = img + this.pubDate = Date().time + this.category = RssDataType.GURU.name + + }) { +// CoroutineScope(Dispatchers.IO).launch { +// service.sendMsg("${title}\n${img}\n${href}") +// } + } + } +// lists.map { +// service.sendMsg("${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}") +// } + } +} + +object SkFWTW9zdA : SoInterface { + var dmy = SimpleDateFormat("dd-MM-yyyy") + override fun getName(): String { + return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray())) + } + override fun parse(doc: org.jsoup.nodes.Document, service: RssDataService) { + var lists = arrayListOf() + doc.getElementsByClass("card").forEach { card -> + var thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("src") else "" + if (thumb.contains("No+Poster")) thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("data-src") else thumb + var model = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("alt") else "" + if(card.getElementsByClass("card-block").size > 0) if(card.getElementsByClass("card-block").size > 0) { + val link = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("href") + val title = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("title") + val date = card.getElementsByTag("span").get(0).text() + service.save(RssData().apply { + lists.add(this) + description = model + thumbnail = thumb + originPage = link + this.title = title + category = RssDataType.Most.name + try { + pubDate = dmy.parse(date).time + }catch (e : Exception) {e.printStackTrace()} + }){ +// CoroutineScope(Dispatchers.IO).launch { +// service.sendMsg("${title}\n${thumb}\n${link}") +// } + } + } + } +// service.sendMsg(lists.map { +// "${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}\n" +// }.joinToString(" \n ")) + } +} +@Repository +interface RssDataRepository : ReactiveMongoRepository { + fun findFirstByOriginPageEquals(originPage : String): Mono + fun findAllByOrderByPubDate() : Mono> + fun save(log: RssData): Mono +} + +@Service +class RssDataService { + @Autowired + private lateinit var logService: LogService + + @Autowired + private lateinit var rssDataRepository: RssDataRepository + fun hasItem(originPage : String) { + + } + fun getLocationLog() : List? { + return rssDataRepository.findAllByOrderByPubDate().block() + } + + + fun save(log: RssData, callback : (Boolean)->Unit) { + println("saved msg before ${Gson().toJson(log)}") + log.originPage?.let { + if(rssDataRepository.findFirstByOriginPageEquals(it).block() == null) { + rssDataRepository.save(log) + .subscribe({ println("saved msg after ${it}") }, { e -> e.printStackTrace() }, { + println("saved msg comp") + callback(true) + }) + } else { + println("있어???") + } + } + } + + @Autowired + lateinit var globalEvv : GlobalEnvironment + + suspend fun sendMsg(data : String) { + val client = WebClient.create() + client.get() + .uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${data}") + .retrieve() + .bodyToMono(String::class.java).block() ?: "FAIL" + } +} + + +enum class Visibility { + PUBLIC, // 전체 공개 + MEMBERS, // 회원 공개 + PRIVATE // 비공개 (나만 보기) +} + +enum class MetadataStatus { + PENDING, // 처리 대기 중 + COMPLETED, // 처리 완료 + FAILED // 처리 실패 +} + + +@Document(collection = "WebBookmark") +data class WebBookmark( + @BsonId + @BsonRepresentation(BsonType.OBJECT_ID) + var id: String? = null, + + var userId: String, // 누가 저장했는지 + var url: String, // 원본 페이지 URL + var title: String? = null, // 페이지 제목 + var description: String? = null, // 페이지 요약 (메타 태그) + var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그) + var userComment: String? = null, // 사용자가 남긴 짧은 의견 + var tags: List? = null, // 태그 (예: #kotlin, #spring) + var savedAt: Long = System.currentTimeMillis(), // 저장 시간 + // [신규 추가] 공개 범위 필드. 기본값은 PRIVATE. + var visibility: String = Visibility.PRIVATE.name, + // [신규 추가] 좋아요/싫어요 카운트 필드 + var voteCount: Long = 0, + var unlikeCount: Long = 0, + var userSelectedImageUrl: String? = null, + var metadataStatus: String = MetadataStatus.PENDING.name +) + +@Repository +interface WebBookmarkRepository : ReactiveMongoRepository { + fun findByUserIdOrderBySavedAtDesc(userId: String): Flux + fun findByVisibilityInOrderBySavedAtDesc(visibilities: List, pageable: Pageable): Flux + fun countByVisibilityIn(visibilities: List): Mono + + fun findByMetadataStatus(status: String): Flux +} + +@Service +class WebBookmarkService(private val repository: WebBookmarkRepository, + private val reactiveMongoTemplate: ReactiveMongoTemplate + // [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다. +) { + fun getBookmarksForUser(userId: String): Flux { + return repository.findByUserIdOrderBySavedAtDesc(userId) + } + + fun saveBookmark(bookmark: WebBookmark): Mono { + // 여기에 중복 저장 방지 로직 등을 추가할 수 있음 + return repository.save(bookmark) + } + + // 필요하다면 삭제, 수정 기능 추가 + fun deleteBookmark(id: String): Mono { + return repository.deleteById(id) + } + + // [신규 추가] 사용자의 권한에 따라 볼 수 있는 북마크 목록을 페이지네이션으로 조회 + fun getVisibleBookmarks(userDetails: UserDetails?, pageable: Pageable): Mono> { + val visibleScopes = when { + // 관리자일 경우 모든 북마크 조회 가능 (필요 시 추가) + // userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true -> + // listOf(Visibility.PUBLIC.name, Visibility.MEMBERS.name, Visibility.PRIVATE.name) + + // 로그인 사용자일 경우 PUBLIC과 MEMBERS 조회 가능 + userDetails != null -> listOf(Visibility.PUBLIC.name, Visibility.MEMBERS.name) + + // 비로그인 사용자일 경우 PUBLIC만 조회 가능 + else -> listOf(Visibility.PUBLIC.name) + } + + val bookmarks = repository.findByVisibilityInOrderBySavedAtDesc(visibleScopes, pageable).collectList() + val totalCount = repository.countByVisibilityIn(visibleScopes) + + // Mono.zip을 사용하여 두 비동기 작업(목록 조회, 카운트)을 병렬로 실행 + return Mono.zip(bookmarks, totalCount).map { tuple -> + PageImpl(tuple.t1, pageable, tuple.t2) + } + } + + /** + * [신규 추가] + * 북마크의 좋아요 카운트를 1 증가시킵니다. + * @param bookmarkId 대상 북마크의 ID + * @return 업데이트된 WebBookmark 객체 + */ + fun incrementVote(bookmarkId: String): Mono { + val query = Query.query(Criteria.where("id").`is`(bookmarkId)) + val update = Update().inc("voteCount", 1) + val options = FindAndModifyOptions.options().returnNew(true) + return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java) + } + + /** + * [신규 추가] + * 북마크의 싫어요 카운트를 1 증가시킵니다. + * @param bookmarkId 대상 북마크의 ID + * @return 업데이트된 WebBookmark 객체 + */ + fun incrementUnlike(bookmarkId: String): Mono { + val query = Query.query(Criteria.where("id").`is`(bookmarkId)) + val update = Update().inc("unlikeCount", 1) + val options = FindAndModifyOptions.options().returnNew(true) + return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt index e1ed250..a5738b8 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt @@ -26,7 +26,12 @@ import javax.imageio.ImageIO import kotlin.random.Random import org.springframework.data.repository.kotlin.CoroutineCrudRepository import org.springframework.data.mongodb.core.index.Indexed +import org.springframework.data.repository.reactive.ReactiveSortingRepository +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserDetails import java.io.File +import java.time.Instant import java.util.UUID @@ -501,3 +506,141 @@ interface SudokuPuzzleRepository : CoroutineCrudRepository suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle? suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle? } + + +/** + * 모든 게임의 랭킹을 저장하는 통합 모델 + */ +@Document(collection = "game_ranks") +data class GameRank( + @Id + val id: String? = null, + val gameType: GameType, // 게임 종류 (2048, SUDOKU, SPIDER 등) + val contextId: String?, // 게임의 세부 ID (예: 스도쿠 퍼즐 Key, 스파이더 난이도, 노노그램 퍼즐 ID) + val playerName: String, // 표준화된 플레이어 이름 필드 + + /** * 기본 점수 필드 (정렬 1순위). + * - 2048: 점수 (높을수록 좋음) + * - Sudoku: 완료 시간(초) (낮을수록 좋음) + * - Spider: 이동 횟수 (낮을수록 좋음) + */ + val primaryScore: Long, + + /** * 보조 점수 필드 (정렬 2순위. 예: 스파이더의 완료 시간). + */ + val secondaryScore: Long? = null, + + val timestamp: Instant = Instant.now() +) + +/** + * 지원하는 게임 타입을 정의하는 Enum + */ +enum class GameType { + GAME_2048, + SUDOKU, + SPIDER, + NONOGRAM +} + +/** + * 랭킹 등록 시 모든 프론트엔드에서 공통으로 사용할 DTO + */ +data class UnifiedRankDto( + val gameType: GameType, + val contextId: String?, + val playerName: String, + val primaryScore: Long, + val secondaryScore: Long? = null +) + +@Repository +interface GameRankRepository : ReactiveSortingRepository { + + fun save(gameRank: GameRank): Mono + // 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048) + fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc( + gameType: GameType, + contextId: String? + ): Flux + + // 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수) + fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc( + gameType: GameType, + contextId: String? + ): Flux + + // [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회 + fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux +} + + +@Service +class GameRankService( + private val rankRepository: GameRankRepository, + private val userManager: UserManager ) { + /** + * 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다. + */ + fun getRanks(gameType: GameType, contextId: String?): Flux { + return when (gameType) { + // 점수가 높아야 하는 게임 (2048) + GameType.GAME_2048 -> + rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(gameType, contextId) + + // 점수가 낮아야 하는 게임 (스도쿠 시간, 스파이더 무브/시간, 노노그램 시간) + GameType.SUDOKU, GameType.SPIDER, GameType.NONOGRAM -> + rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(gameType, contextId) + } + } + + /** + * [수정] 공통 DTO를 받아 랭킹을 저장 (사용자 이름 중복 체크 로직 추가) + */ + fun submitRank(rankDto: UnifiedRankDto): Mono { + val auth = SecurityContextHolder.getContext().authentication + + // 로그인 사용자인지, 비로그인(익명) 사용자인지 확인 + val isAuthenticated = auth != null && auth.isAuthenticated && auth !is AnonymousAuthenticationToken + + if (isAuthenticated) { + // 로그인 사용자: DTO의 playerName을 실제 로그인한 사용자의 ID로 강제 설정 (보안 강화) + val principal = auth.principal as UserDetails + val authenticatedUsername = principal.username + + val gameRank = GameRank( + gameType = rankDto.gameType, + contextId = rankDto.contextId, + playerName = authenticatedUsername, // 실제 인증된 이름 사용 + primaryScore = rankDto.primaryScore, + secondaryScore = rankDto.secondaryScore + ) + return rankRepository.save(gameRank) + } else { + // 비로그인 사용자: 입력한 이름이 기존 회원 ID와 중복되는지 확인 + return userManager.findById(rankDto.playerName) + .flatMap { existingUser -> + // 사용자가 존재하면 에러 발생 + Mono.error(IllegalArgumentException("이미 등록된 회원의 이름입니다. 다른 이름을 사용해주세요.")) + } + .switchIfEmpty(Mono.defer { + // 사용자가 존재하지 않으면 랭킹 저장 진행 + val gameRank = GameRank( + gameType = rankDto.gameType, + contextId = rankDto.contextId, + playerName = rankDto.playerName, + primaryScore = rankDto.primaryScore, + secondaryScore = rankDto.secondaryScore + ) + rankRepository.save(gameRank) + }) + } + } + + /** + * [신규 추가] 특정 플레이어의 모든 게임 랭킹을 조회합니다. + */ + fun getRanksByPlayer(playerName: String): Flux { + return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName) + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/TelegramUpdate.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/TelegramUpdate.kt index cbd5481..12e156c 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/TelegramUpdate.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/TelegramUpdate.kt @@ -48,7 +48,7 @@ class From { @NoArgsConstructor @AllArgsConstructor @Document(collection = "TelegramMessage") -class Message { +class TlgMessage { @Id var message_id: String = "" @@ -89,7 +89,7 @@ class TelegramLocation { class Result { var update_id: Int = 0 - var message: Message? = null + var message: TlgMessage? = null } class TelegramUpdate { @@ -100,17 +100,17 @@ class TelegramUpdate { } @Repository -interface TelegramRepository : ReactiveMongoRepository { +interface TelegramRepository : ReactiveMongoRepository { @Query("{id :?0}") - override fun findById(id: String): Mono + override fun findById(id: String): Mono @Query("{id :?0}") fun count(id: Int): Mono - fun save(message: Message): Mono + fun save(message: TlgMessage): Mono } interface MsgService { - fun findById(id: String): Mono? + fun findById(id: String): Mono? } @Service @@ -123,7 +123,7 @@ class TelegramMsgService : MsgService { - override fun findById(id: String): Mono? { + override fun findById(id: String): Mono? { return telegramRepository.findById(id) } @@ -132,7 +132,7 @@ class TelegramMsgService : MsgService { return telegramRepository.count(id) } - fun save(msg: Message) { + fun save(msg: TlgMessage) { println("saved msg before ${msg}") telegramRepository.save(msg).subscribe( { println("saved msg after ${it}") },{e -> e.printStackTrace()},{ println("saved msg comp") @@ -166,3 +166,59 @@ class GSRItemPageMap { var cse_image : ArrayList>? = null } + +class Condition { + var text: String? = null + var icon: String? = null + var code: Int = 0 +} + +class Current { + var last_updated_epoch: Int = 0 + var last_updated: String? = null + var temp_c: Double = 0.0 + var temp_f: Double = 0.0 + var is_day: Int = 0 + var condition: Condition? = null + var wind_mph: Double = 0.0 + var wind_kph: Double = 0.0 + var wind_degree: Int = 0 + var wind_dir: String? = null + var pressure_mb: Double = 0.0 + var pressure_in: Double = 0.0 + var precip_mm: Double = 0.0 + var precip_in: Double = 0.0 + var humidity: Int = 0 + var cloud: Int = 0 + var feelslike_c: Double = 0.0 + var feelslike_f: Double = 0.0 + var windchill_c: Double = 0.0 + var windchill_f: Double = 0.0 + var heatindex_c: Double = 0.0 + var heatindex_f: Double = 0.0 + var dewpoint_c: Double = 0.0 + var dewpoint_f: Double = 0.0 + var vis_km: Double = 0.0 + var vis_miles: Double = 0.0 + var uv: Double = 0.0 + var gust_mph: Double = 0.0 + var gust_kph: Double = 0.0 +} + +class Location { + var name: String? = null + var region: String? = null + var country: String? = null + var lat: Double = 0.0 + var lon: Double = 0.0 + var tz_id: String? = null + var localtime_epoch: Int = 0 + var localtime: String? = null +} + +class CurrentWeather { + var location: Location? = null + var current: Current? = null + fun getSummaryInfo(lat : String,lon : String) = "지역:${this.location?.name}\n날씨:${this.current?.condition?.text}\n온도:${this.current?.temp_c}\n습도:${this.current?.humidity}\n" + + "체감온도:${this.current?.feelslike_c}\nhttps://www.accuweather.com/ko/search-locations?query=${lat},${lon}" +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt index 9b5cedd..e648f0c 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt @@ -13,6 +13,7 @@ import org.springframework.data.mongodb.repository.Query import org.springframework.data.mongodb.repository.ReactiveMongoRepository import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Repository import org.springframework.stereotype.Service @@ -179,8 +180,13 @@ class UserManager( override fun loadUserByUsername(username: String?): UserDetails { - logService.log("username ${username}") - var user = findById(username!!)?.blockOptional(Duration.ofMillis(5000L))?.get() ?: User() + if (username == null) { + throw UsernameNotFoundException("Username cannot be null") + } + // 사용자를 찾지 못하면 예외를 던지도록 수정 + val user = findById(username) + .blockOptional(Duration.ofMillis(5000L)) + .orElseThrow { UsernameNotFoundException("User not found: $username") } val userRole = user.getRole().name // "READ", "WRITE", 또는 "ADMIN" diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Weather.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Weather.kt deleted file mode 100644 index d4e38f7..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/Weather.kt +++ /dev/null @@ -1,57 +0,0 @@ -package kr.lunaticbum.back.lun.model - -class Condition { - var text: String? = null - var icon: String? = null - var code: Int = 0 -} - -class Current { - var last_updated_epoch: Int = 0 - var last_updated: String? = null - var temp_c: Double = 0.0 - var temp_f: Double = 0.0 - var is_day: Int = 0 - var condition: Condition? = null - var wind_mph: Double = 0.0 - var wind_kph: Double = 0.0 - var wind_degree: Int = 0 - var wind_dir: String? = null - var pressure_mb: Double = 0.0 - var pressure_in: Double = 0.0 - var precip_mm: Double = 0.0 - var precip_in: Double = 0.0 - var humidity: Int = 0 - var cloud: Int = 0 - var feelslike_c: Double = 0.0 - var feelslike_f: Double = 0.0 - var windchill_c: Double = 0.0 - var windchill_f: Double = 0.0 - var heatindex_c: Double = 0.0 - var heatindex_f: Double = 0.0 - var dewpoint_c: Double = 0.0 - var dewpoint_f: Double = 0.0 - var vis_km: Double = 0.0 - var vis_miles: Double = 0.0 - var uv: Double = 0.0 - var gust_mph: Double = 0.0 - var gust_kph: Double = 0.0 -} - -class Location { - var name: String? = null - var region: String? = null - var country: String? = null - var lat: Double = 0.0 - var lon: Double = 0.0 - var tz_id: String? = null - var localtime_epoch: Int = 0 - var localtime: String? = null -} - -class CurrentWeather { - var location: Location? = null - var current: Current? = null - fun getSummaryInfo(lat : String,lon : String) = "지역:${this.location?.name}\n날씨:${this.current?.condition?.text}\n온도:${this.current?.temp_c}\n습도:${this.current?.humidity}\n" + - "체감온도:${this.current?.feelslike_c}\nhttps://www.accuweather.com/ko/search-locations?query=${lat},${lon}" -} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/BookmarkProcessorService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/BookmarkProcessorService.kt new file mode 100644 index 0000000..c5751c6 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/BookmarkProcessorService.kt @@ -0,0 +1,69 @@ +package kr.lunaticbum.back.lun.service + + +import kr.lunaticbum.back.lun.model.MetadataStatus +import kr.lunaticbum.back.lun.model.WebBookmark +import kr.lunaticbum.back.lun.model.WebBookmarkRepository +import kr.lunaticbum.back.lun.utils.LogService +import org.jsoup.Jsoup +import org.springframework.data.mongodb.repository.ReactiveMongoRepository +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers + +@Service +class BookmarkProcessorService( + private val bookmarkRepository: WebBookmarkRepository, + private val logService: LogService +) { + + // fixedDelayString = "60000" -> 1분에 한 번씩 실행 + @Scheduled(fixedDelayString = "60000") + fun processPendingBookmarks() { + logService.log("Starting scheduled job: Process Pending Bookmarks...") + + bookmarkRepository.findByMetadataStatus(MetadataStatus.PENDING.name) // PENDING 상태인 북마크 조회 + .flatMap { bookmark -> + // 각 북마크에 대해 메타데이터를 가져오고 DB를 업데이트하는 비동기 작업을 수행 + fetchAndApplyMetadata(bookmark) + } + .subscribe( + { updatedBookmark -> logService.log("Successfully processed bookmark ID: ${updatedBookmark.id}") }, + { error -> logService.log("Error during bookmark processing: ${error.message}") } + ) + } + + private fun fetchAndApplyMetadata(bookmark: WebBookmark): Mono { + return Mono.fromCallable { + // Jsoup 호출은 블로킹(blocking) 작업이므로 fromCallable로 감싸고 + // 별도 스레드에서 실행되도록 subscribeOn을 사용 + logService.log("Fetching metadata for: ${bookmark.url}") + val doc = Jsoup.connect(bookmark.url).timeout(10000).get() // 10초 타임아웃 + + // 메타데이터 추출 + val title = doc.select("meta[property=og:title]").attr("content").ifEmpty { doc.title() } + val description = doc.select("meta[property=og:description]").attr("content") + val imageUrl = doc.select("meta[property=og:image]").attr("content") + + // 북마크 객체 업데이트 + bookmark.title = title + bookmark.description = description + bookmark.thumbnailUrl = imageUrl + bookmark.metadataStatus = MetadataStatus.COMPLETED.name // 상태를 COMPLETED로 변경 + bookmark + } + .subscribeOn(Schedulers.boundedElastic()) + .flatMap { updatedBookmark -> + // 업데이트된 북마크를 DB에 저장 + bookmarkRepository.save(updatedBookmark) + } + .onErrorResume { error -> + // 오류 발생 시 상태를 FAILED로 변경하여 저장 + logService.log("Failed to fetch metadata for URL: ${bookmark.url}. Error: ${error.message}") + bookmark.metadataStatus = MetadataStatus.FAILED.name + bookmarkRepository.save(bookmark) + } + } +} diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/utils/JwtUtil.kt b/src/main/kotlin/kr/lunaticbum/back/lun/utils/JwtUtil.kt index 85aa305..562068e 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/utils/JwtUtil.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/utils/JwtUtil.kt @@ -1,87 +1,65 @@ +// kr/lunaticbum/back/lun/utils/JwtUtil.kt + package kr.lunaticbum.back.lun.utils -import io.jsonwebtoken.* +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm import io.jsonwebtoken.security.Keys -import jakarta.servlet.http.Cookie -import kr.lunaticbum.back.lun.configs.JwtRule -import kr.lunaticbum.back.lun.configs.TokenStatus -import lombok.RequiredArgsConstructor -import lombok.extern.slf4j.Slf4j -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.nio.charset.StandardCharsets +import org.springframework.beans.factory.annotation.Value +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.stereotype.Component import java.security.Key import java.util.* +import java.util.function.Function - -@Slf4j -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor +@Component class JwtUtil { - fun getTokenStatus(token: String?, secretKey: Key?): TokenStatus { - try { - var cls = Jwts.parserBuilder() - .setSigningKey(secretKey) - .build() - .parseClaimsJws(token) - cls.body.keys.forEach { - println("${it} >>> ${cls.body.get(it).toString()}") - } - return TokenStatus.AUTHENTICATED - } catch (e: ExpiredJwtException) { -// log.error(INVALID_EXPIRED_JWT.getMessage()) - return TokenStatus.EXPIRED - } catch (e: IllegalArgumentException) { -// log.error(INVALID_EXPIRED_JWT.getMessage()) - return TokenStatus.EXPIRED - } catch (e: JwtException) { - throw BusinessException(ErrorCode.INVALID_JWT) - } + @Value("\${jwt.secret}") + private lateinit var secret: String + + @Value("\${jwt.expiration}") + private lateinit var expirationTime: String + + private fun getSigningKey(): Key { + return Keys.hmacShaKeyFor(secret.toByteArray()) } - fun resolveTokenFromCookie(cookies: Array?, tokenPrefix: JwtRule): String { - return Arrays.stream(cookies) - .filter { cookie -> cookie.getName().equals(tokenPrefix.value, ignoreCase = true) } - .findFirst() - .map { it.value } - .orElse("") + fun generateToken(userDetails: UserDetails): String { + val claims = mutableMapOf() + return Jwts.builder() + .setClaims(claims) + .setSubject(userDetails.username) + .setIssuedAt(Date(System.currentTimeMillis())) + .setExpiration(Date(System.currentTimeMillis() + expirationTime.toLong())) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact() } - fun getSigningKey(secretKey: String): Key { - val encodedKey = encodeToBase64(secretKey) - return Keys.hmacShaKeyFor(encodedKey.toByteArray(StandardCharsets.UTF_8)) + fun extractUsername(token: String): String { + return extractClaim(token, Claims::getSubject) } - private fun encodeToBase64(secretKey: String): String { - return Base64.getEncoder().encodeToString(secretKey.toByteArray()) + fun isTokenValid(token: String, userDetails: UserDetails): Boolean { + val username = extractUsername(token) + return (username == userDetails.username && !isTokenExpired(token)) } - fun resetToken(tokenPrefix: JwtRule): Cookie { - val cookie: Cookie = Cookie(tokenPrefix.value, null) - cookie.setMaxAge(0) - cookie.setPath("/") - return cookie + private fun isTokenExpired(token: String): Boolean { + return extractExpiration(token).before(Date()) } - fun extractToken(token: String?, secretKey: Key?): Jws? { - try { - return Jwts.parserBuilder() - .setSigningKey(secretKey) - .build() - .parseClaimsJws(token) - } catch (e: JwtException) { - throw BusinessException(ErrorCode.INVALID_JWT) - } + private fun extractExpiration(token: String): Date { + return extractClaim(token, Claims::getExpiration) } -} -class BusinessException(error : ErrorCode) : Exception(error.name) -enum class ErrorCode { - JWT_TOKEN_NOT_FOUND, - NOT_AUTHENTICATED_USER, - INVALID_EXPIRED_JWT, - INVALID_JWT + private fun extractClaim(token: String, claimsResolver: Function): T { + val claims = extractAllClaims(token) + return claimsResolver.apply(claims) + } + + private fun extractAllClaims(token: String): Claims { + return Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token).body + } } \ No newline at end of file diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 5f5d41f..a3e8fc1 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -100,4 +100,7 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE server.tomcat.connection-timeout=60s # For reactive applications (like yours), also set this timeout spring.webflux.response-timeout=60s -api.base-url=ss \ No newline at end of file +api.base-url=ss +build.config.run=local +jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long +jwt.expiration=86400000 \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 5f5d41f..316221e 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -100,4 +100,7 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE server.tomcat.connection-timeout=60s # For reactive applications (like yours), also set this timeout spring.webflux.response-timeout=60s -api.base-url=ss \ No newline at end of file +api.base-url=ss +build.config.run=prd +jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long +jwt.expiration=86400000 \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5f5d41f..6d1aa5e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -100,4 +100,8 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE server.tomcat.connection-timeout=60s # For reactive applications (like yours), also set this timeout spring.webflux.response-timeout=60s -api.base-url=ss \ No newline at end of file +api.base-url=ss + +build.config.run=local +jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long +jwt.expiration=86400000 \ No newline at end of file diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index 0c6458f..d02db0a 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -233,6 +233,8 @@ window.addEventListener('DOMContentLoaded', () => { closePopup(); }); } + + checkUnreadMessages(); // 함수 호출 추가 }); /* --- (DOMContentLoaded 끝) --- */ @@ -815,6 +817,7 @@ function gotoHome() { document.location.replace(`${getMainPath()}/home.bs`); } function gotoWrite() { document.location.replace(`${getMainPath()}/blog/edit`); } function gotoModify() { document.location.replace(`${getMainPath()}/blog/posts`); } function gotoWhere() { document.location.replace(`${getMainPath()}/bums/where.bs`); } +function gotoBUMSpace() { document.location.replace(`${getMainPath()}/bums/face.bs`); } function gotoJoin() { document.location.replace(`${getMainPath()}/user/join.bs`); } // [추가] 네모로직 업로드 페이지로 이동하는 함수 function gotoPuzzleUpload() { document.location.replace(`${getMainPath()}/puzzle/upload.bs`); } @@ -1551,4 +1554,227 @@ async function showConfirm(title, text) { cancelButtonText: '취소' }); return result.isConfirmed; +} +function sendTlg(form, type,keyword) { + console.log(form) + let data = { + 'name': form.querySelector("#name").value, + 'email': form.querySelector("#email").value, + 'message': form.querySelector("#message").value, + } + if (data.name != null && data.email != null && data.message != null && data.message.length > 0) { + if(confirm(JSON.stringify(data) + "\n해당 내용으로\n메시지 보내쉴?")) { + post(getMainPath()+"/tlg/repotToMe.bjx",type,JSON.stringify(data),keyword, function (resultData) { + showAlert("서버에 전달됨.") + }) + } else { + + } + } + return false +} + + +async function checkUnreadMessages() { + const isLoggedIn = !!document.querySelector('a[href="javascript:logout()"]'); + if (!isLoggedIn) return; // 비로그인 상태면 실행 중단 + + try { + const response = await fetch('/messages/unread-count'); + if (response.ok) { + const data = await response.json(); + if (data.count > 0) { + const icon = document.getElementById('message-icon'); + if (icon) { + icon.style.display = 'inline-block'; // 아이콘 표시 + } + } + } + } catch (error) { + console.error('Failed to check for unread messages:', error); + } +} +function handleBookmarkVote(buttonElement, voteType) { + const controls = buttonElement.closest('.vote-controls'); + const bookmarkId = controls.dataset.bookmarkId; + controls.querySelectorAll('button').forEach(btn => btn.disabled = true); // 중복 클릭 방지 + + // [수정] 북마크용 API 엔드포인트 사용 + const url = `${getMainPath()}/bookmarks/${bookmarkId}/${voteType === 'like' ? 'like' : 'unlike'}`; + + // CSRF 토큰 준비 + const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content'); + const headers = { 'X-CSRF-TOKEN': csrfToken }; + + fetch(url, { method: 'POST', headers: headers }) + .then(res => res.json()) + .then(data => { + controls.querySelector('.like-count').innerText = data.voteCount; + controls.querySelector('.unlike-count').innerText = data.unlikeCount; + }) + .catch(error => console.error('Error handling bookmark vote:', error)) + .finally(() => { + controls.querySelectorAll('button').forEach(btn => btn.disabled = false); + }); +} + +/** + * 특정 북마크의 댓글 섹션을 열거나 닫습니다. + */ +function toggleCommentSection(bookmarkId) { + const section = document.getElementById(`comment-section-${bookmarkId}`); + if (section.style.display === 'none') { + section.style.display = 'block'; + fetchBookmarkComments(bookmarkId); // 처음 열 때 댓글 로드 + } else { + section.style.display = 'none'; + } +} + +/** + * 특정 북마크의 댓글 목록을 불러옵니다. + */ +async function fetchBookmarkComments(bookmarkId) { + const listContainer = document.getElementById(`comments-list-${bookmarkId}`); + listContainer.innerHTML = '댓글 로딩 중...'; + + const response = await fetch(`${getMainPath()}/bookmarks/${bookmarkId}/comments`); + const data = await response.json(); + + listContainer.innerHTML = ''; + if (data.resultCode === 0 && data.comments.length > 0) { + data.comments.forEach(comment => { + // 기존 블로그 댓글 HTML 생성 함수 재사용 + listContainer.innerHTML += createCommentHTML(comment); + }); + } else { + listContainer.innerHTML = '아직 댓글이 없습니다.'; + } +} + +/** + * 북마크에 댓글을 등록합니다. + */ +function submitBookmarkComment(bookmarkId) { + const input = document.getElementById(`comment-input-${bookmarkId}`); + const content = input.value.trim(); + if (!content) { + showAlert('알림', '댓글 내용을 입력하세요.'); + return; + } + + // 블로그 댓글과 동일한 DTO 및 암호화 방식 사용 + const commentData = { content: content, parentId: null }; + const uploadUrl = `${getMainPath()}/bookmarks/${bookmarkId}/comments`; + + // 기존 `post` 유틸리티 함수를 재사용하여 서버에 전송 + post(uploadUrl, serverData.enc, JSON.stringify(commentData), serverData.keyword, (resultData) => { + const response = JSON.parse(resultData); + if (response.resultCode === 0) { + input.value = ''; + fetchBookmarkComments(bookmarkId); // 댓글 목록 새로고침 + } else { + showAlert('오류', '댓글 등록에 실패했습니다: ' + response.resultMsg); + } + }); +} +/** + * 북마크 클릭 시 사용자에게 선택지를 보여주는 함수 + * @param {HTMLElement} element - 클릭된 요소 + */ +async function showBookmarkOptions(element) { + const url = element.dataset.url; + const title = element.dataset.title; + + const result = await Swal.fire({ + title: '어떻게 보시겠어요?', + text: title, + icon: 'question', + showDenyButton: true, + confirmButtonText: '새 탭에서 열기', + denyButtonText: '여기서 보기 (Iframe)', + confirmButtonColor: '#3085d6', + denyButtonColor: '#555', + }); + + if (result.isConfirmed) { + // '새 탭에서 열기' 선택 시 + window.open(url, '_blank'); + } else if (result.isDenied) { + // '여기서 보기 (Iframe)' 선택 시 + openBookmarkInIframe(url, title); + } +} + +/** + * iframe 로드 실패 시 일관된 처리를 위한 헬퍼 함수 + * @param {string} title - 북마크 제목 + * @param {string} url - 북마크 URL + */ +function handleIframeLoadFailure(title, url) { + closePopup(); // 팝업 닫기 + if (confirm(`'${title}' 페이지를 내부에서 여는 데 실패했습니다.\n\n새 탭에서 여시겠습니까?`)) { + window.open(url, '_blank'); + } +} + +/** + * 지정된 URL을 Iframe 팝업으로 여는 함수 (try-catch 로직 적용) + * @param {string} url - 표시할 URL + * @param {string} title - 표시할 제목 + */ +function openBookmarkInIframe(url, title) { + const popup = document.getElementById('iframe-viewer-popup'); + const titleElement = document.getElementById('iframe-viewer-title'); + const iframe = document.getElementById('bookmark-iframe'); + const overlay = document.querySelector('.dim_layer'); + const newTabLink = document.getElementById('iframe-open-new-tab-link'); + + if (!popup || !titleElement || !iframe || !overlay || !newTabLink) { + console.error('Iframe viewer elements not found!'); + return; + } + + // iframe의 로딩을 시작하기 전에 src를 초기화하여 이전 상태를 지웁니다. + iframe.src = 'about:blank'; + + // iframe의 onload 이벤트 핸들러 + iframe.onload = () => { + console.log("iframe onload 이벤트 발생. 내부 문서 접근을 시도합니다..."); + + try { + // 동일 출처 정책(Same-Origin Policy)을 위반하는 접근 시도 + // 이 코드가 오류를 발생시키면, 다른 출처의 문서가 로드된 것 (성공 또는 오류 페이지) + const dummyAccess = iframe.contentWindow.location.href; + + // 만약 위 코드에서 오류가 발생하지 않았다면, iframe이 동일 출처이거나 비어있다는 의미. + // 외부 사이트 로드는 실패한 것으로 간주합니다. + console.warn("iframe 접근이 차단되지 않았습니다. 로드 실패로 간주합니다."); + handleIframeLoadFailure(title, url); + + } catch (e) { + + // SecurityError가 발생! 다른 출처의 문서가 성공적으로 로드되었다고 간주합니다. + // (이것이 실제 콘텐츠일 수도, 브라우저의 오류 페이지일 수도 있습니다) + console.log("iframe 접근이 보안 정책에 의해 차단되었습니다. 일단 성공으로 간주합니다.", e); + // 팝업을 그대로 유지 + } + }; + + // 네트워크 오류 등으로 iframe 로드 자체가 실패했을 때를 위한 핸들러 + iframe.onerror = () => { + console.error("iframe onerror 이벤트 발생. 로드 실패로 처리합니다."); + handleIframeLoadFailure(title, url); + }; + + // 제목과 새 탭 링크 설정 + titleElement.textContent = title; + newTabLink.href = url; + + // 실제 URL로 로딩 시작 + iframe.src = url; + + // 팝업과 오버레이 표시 + overlay.style.display = 'block'; + popup.style.display = 'block'; } \ No newline at end of file diff --git a/src/main/resources/templates/content/about_view.html b/src/main/resources/templates/content/about_view.html new file mode 100644 index 0000000..3ed2219 --- /dev/null +++ b/src/main/resources/templates/content/about_view.html @@ -0,0 +1,38 @@ + + + + + + + +
+
+
+

소개글 제목

+

+ 최종 수정일: +

+
+
+
+ +
+
+
+
+
+
+
+ + + + +
+ \ No newline at end of file diff --git a/src/main/resources/templates/content/bookmarks.html b/src/main/resources/templates/content/bookmarks.html new file mode 100644 index 0000000..b1711e2 --- /dev/null +++ b/src/main/resources/templates/content/bookmarks.html @@ -0,0 +1,83 @@ + + + + Bookmarks + + +
+
+
+

Bookmarks

+

다른 사용자들이 저장한 유용한 페이지들을 둘러보세요.

+
+
+
+ +
+
+
+
+

아직 저장된 페이지가 없습니다.

+
+
+
+ + Thumbnail + +
+
+

북마크 제목

+

+
+

+ +
+
+ + +
+ 댓글 보기 + +
+ +
+ by +
+
+
+
+
+ + +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/content/editor.html b/src/main/resources/templates/content/editor.html index e19d8ea..b2007af 100644 --- a/src/main/resources/templates/content/editor.html +++ b/src/main/resources/templates/content/editor.html @@ -7,11 +7,7 @@ > - - - - - + @@ -110,6 +106,11 @@ + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/content/licenses.html b/src/main/resources/templates/content/licenses.html index 82721ab..b506bec 100644 --- a/src/main/resources/templates/content/licenses.html +++ b/src/main/resources/templates/content/licenses.html @@ -1,7 +1,10 @@ - + + + + diff --git a/src/main/resources/templates/content/messages/inbox.html b/src/main/resources/templates/content/messages/inbox.html new file mode 100644 index 0000000..d8ce217 --- /dev/null +++ b/src/main/resources/templates/content/messages/inbox.html @@ -0,0 +1,124 @@ + + + + + + + + +
+
+
+

+
+ +
+
    +
  • 받은 쪽지가 없습니다.
  • +
  • +
    +
    + + +
    + +
    +
    +

    +
    +
    +

    답장 보내기

    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
  • +
+
+
+
+ + +
+ \ No newline at end of file diff --git a/src/main/resources/templates/content/user/my_info.html b/src/main/resources/templates/content/user/my_info.html index 9142ce5..e667bb3 100644 --- a/src/main/resources/templates/content/user/my_info.html +++ b/src/main/resources/templates/content/user/my_info.html @@ -15,6 +15,49 @@ .user-list li, .post-list li { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #eee; } .user-list li:last-child, .post-list li:last-child { border-bottom: none; } .button.small { margin-left: 0.5em; } + .custom-radio { + display: none; /* 기본 라디오 버튼 숨기기 */ + } + + .custom-label { + position: relative; + padding-left: 25px; /* 라벨 왼쪽에 가짜 버튼을 위한 공간 확보 */ + cursor: pointer; + line-height: 20px; + display: inline-block; + color: #555; /* 라벨 텍스트 색상 */ + } + + /* 가짜 라디오 버튼 (원) 만들기 */ + .custom-label::before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 18px; + height: 18px; + border: 2px solid #ddd; + border-radius: 50%; /* 원 모양 */ + background: #fff; + } + + /* 선택되었을 때 가짜 라디오 버튼 스타일 변경 */ + .custom-radio:checked + .custom-label::before { + border-color: #FFA500; /* 테두리 색상 변경 (사이트의 포인트 색상) */ + background: #FFA500; /* 배경 색상 채우기 */ + } + + /* 선택되었을 때 원 안에 작은 점 추가 */ + .custom-radio:checked + .custom-label::after { + content: ''; + position: absolute; + left: 6px; + top: 6px; + width: 8px; + height: 8px; + border-radius: 50%; + background: white; + }
@@ -31,10 +74,15 @@ + + + + + @@ -78,7 +126,57 @@ +
+
+

새 페이지 저장하기

+
+ + + +
+ 공개 범위: +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ +
+

저장된 목록

+
+
+
+ +
+
+

카드 제목

+

사용자 코멘트가 여기에 들어갑니다.

+
+

원본 페이지 설명...

+
+
+
+
+
+
    @@ -104,7 +202,27 @@
+
+
+
    +
  • 주고받은 쪽지가 없습니다.
  • +
  • +
    + 보냄 + 받음 +
    + + +
    + + 쪽지 제목 +
    + +
  • +
+
+

권한 요청

@@ -154,7 +272,8 @@
  • - Image Thumbnail + + Image Thumbnail

    (배너로 사용 중) @@ -170,6 +289,27 @@
+
+
+

사이트 소개글 관리

+

+ '사이트 소개' 페이지에 표시될 내용입니다. 글을 수정하면 이전 버전은 히스토리로 여기에 남게 됩니다. +

+ + 최신 소개글 수정 + 새 소개글 작성 + + +
수정 히스토리
+ +
+
@@ -315,6 +455,83 @@ alert('작업 중 오류가 발생했습니다.'); }); } + document.addEventListener('DOMContentLoaded', function() { + const urlInput = document.getElementById('bookmark-url-input'); + const preview = document.getElementById('og-preview'); + + let ogData = {}; // OG 파싱 결과를 저장할 변수 + + // URL 입력 필드에서 포커스가 벗어났을 때(onblur) OG 정보 파싱 API 호출 + urlInput.addEventListener('blur', async function() { + const url = this.value.trim(); + if (!url) return; + + try { + const response = await fetch(`/api/og/parse?url=${encodeURIComponent(url)}`); + if (!response.ok) throw new Error('파싱 실패'); + + ogData = await response.json(); + + // 미리보기 UI 업데이트 + document.getElementById('og-title').textContent = ogData.title || '제목 없음'; + document.getElementById('og-description').textContent = ogData.description || ''; + const ogImage = document.getElementById('og-image'); + if (ogData.thumbnailUrl) { + ogImage.src = ogData.thumbnailUrl; + ogImage.style.display = 'block'; + } else { + ogImage.style.display = 'none'; + } + preview.style.display = 'block'; + + } catch (error) { + console.error(error); + preview.style.display = 'none'; + alert('페이지 정보를 가져오는 데 실패했습니다. URL을 확인해주세요.'); + } + }); + + // 저장 버튼 클릭 이벤트 + document.getElementById('save-bookmark-btn').addEventListener('click', async function() { + const comment = document.getElementById('bookmark-comment-input').value.trim(); + + // [수정] 선택된 공개 범위(visibility) 값을 읽어오는 코드 추가 + const visibility = document.querySelector('input[name="visibility"]:checked').value; + + const bookmarkData = { + url: urlInput.value.trim(), + // title, description 등은 ogData에서 가져오는 것은 그대로 유지 + title: ogData.title, + description: ogData.description, + thumbnailUrl: ogData.thumbnailUrl, + userComment: comment, + // [수정] bookmarkData 객체에 visibility 프로퍼티 추가 + visibility: visibility + }; + + if (!bookmarkData.url) { + alert('URL을 입력해주세요.'); + return; + } + + // 서버에 북마크 저장 요청 (이하 코드는 동일) + const response = await fetch('/user/bookmarks/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [csrfHeader]: csrfToken + }, + body: JSON.stringify(bookmarkData) + }); + + if (response.ok) { + alert('페이지가 저장되었습니다.'); + location.reload(); + } else { + alert('저장에 실패했습니다.'); + } + }); + }); \ No newline at end of file diff --git a/src/main/resources/templates/content/viewer.html b/src/main/resources/templates/content/viewer.html index f779810..6f3fdc8 100644 --- a/src/main/resources/templates/content/viewer.html +++ b/src/main/resources/templates/content/viewer.html @@ -5,14 +5,7 @@ layout:decorate="~{layout/default_layout}"> - - - - - + @@ -108,5 +101,14 @@ + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/footer.html b/src/main/resources/templates/fragments/footer.html index b0d628d..08c81f9 100644 --- a/src/main/resources/templates/fragments/footer.html +++ b/src/main/resources/templates/fragments/footer.html @@ -1,5 +1,6 @@ - +> - + + + + + + +