From 4b652c4df51fab450407affa592c9c75dc24a8f6 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Fri, 19 Sep 2025 16:32:24 +0900 Subject: [PATCH] .... --- .../lunaticbum/back/lun/ApiIntegrationTest.kt | 234 ++++++------- .../lunaticbum/back/lun/configs/AppConfig.kt | 51 +-- .../back/lun/configs/BatchConfig.kt | 62 ---- .../back/lun/configs/BumsInterceptor.kt | 38 ++- .../back/lun/configs/JwtGenerator.kt | 142 ++++---- .../back/lun/configs/RootAppContext.kt | 29 -- .../back/lun/configs/SecurityConfig.kt | 111 ++++++- .../back/lun/configs/ServletAppContext.kt | 37 --- .../back/lun/controllers/BlogController.kt | 310 ++++++++++++++---- .../back/lun/controllers/UserController.kt | 17 +- .../lunaticbum/back/lun/model/BumsPrivate.kt | 28 -- .../kr/lunaticbum/back/lun/model/GameRank.kt | 150 --------- .../kr/lunaticbum/back/lun/model/Post.kt | 104 +++++- .../back/lun/model/ResponceResult.kt | 2 + .../kr/lunaticbum/back/lun/model/User.kt | 3 +- .../lun/service/BookmarkProcessorService.kt | 26 +- .../resources/application-local.properties | 3 +- .../resources/application-prod.properties | 3 +- src/main/resources/application.properties | 3 +- src/main/resources/static/js/common.js | 131 ++++++++ .../templates/content/bookmarks.html | 160 ++++++--- .../templates/content/error_page.html | 25 ++ .../templates/content/user/my_info.html | 151 ++++++++- .../templates/fragments/includes.html | 4 +- .../templates/layout/default_layout.html | 72 ++++ 25 files changed, 1166 insertions(+), 730 deletions(-) delete mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/configs/BatchConfig.kt delete mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/configs/RootAppContext.kt delete mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/configs/ServletAppContext.kt delete mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/model/BumsPrivate.kt delete mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt create mode 100644 src/main/resources/templates/content/error_page.html diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/ApiIntegrationTest.kt b/src/main/kotlin/kr/lunaticbum/back/lun/ApiIntegrationTest.kt index 2fa9de3..73d4255 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/ApiIntegrationTest.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/ApiIntegrationTest.kt @@ -1,117 +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 +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/AppConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/AppConfig.kt index 8c3a62e..b1b61ec 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/AppConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/AppConfig.kt @@ -37,57 +37,14 @@ class AppConfig : WebMvcConfigurer { registry.addInterceptor(authInterceptor()) .addPathPatterns( "/home.bs", - "/bums/where.bs" , + "/bums/where.bs", + "/user/info", // "내 정보" 페이지도 추가하면 좋습니다. "/tlg/repotToMe.bjx", - "/user/login.bs", "/user/signup.bs","/user/login.bjx", - "/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx" + "/user/login.bs", "/user/signup.bs", "/user/login.bjx", + "/blog/viewer/**", "/blog/posts", "/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx" ) -// super.addInterceptors(registry) } -// @Bean -// fun qdrantClient(): QdrantClient { -// return QdrantClient("https://ollama.lunaticbum.kr:6334") -// } - -// @Bean -// fun chatClient(): OllamaApi { -// return OllamaApi("https://lama.lunaticbum.kr") -// -//// .withDefaultOptions( -//// OllamaOptions.create() -//// .withModel("phi4:14b") -//// .withNumThread(5) -//// .withSeed(5) -//// .withTemperature(0.9f)) -// } -// @Bean -// fun getProperty() : Map{ -// println("telegramBotKey >>>> $telegramBotKey") -// println("telegramMyId >>>> $telegramMyId") -// println("weatherApiKey >>>> $weatherApiKey") -// -// return hashMapOf(Pair("telegramMyId",telegramMyId)) -// } -// @Bean -// fun memberRepository(): MemberRepository { -// return MemoryMemberRepository() -// } -// -// @Bean -// fun discountPolicy(): DiscountPolicy { -// return RateDiscountPolicy() -// } -// -// @Bean -// fun memberService(): MemberService { -// return MemberServiceImpl(memberRepository()) -// } -// -// @Bean -// fun orderService(): OrderService { -// return OrderServiceImpl(memberRepository(), discountPolicy()) -// } } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/BatchConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/BatchConfig.kt deleted file mode 100644 index 6e27c5d..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/BatchConfig.kt +++ /dev/null @@ -1,62 +0,0 @@ -//package kr.lunaticbum.back.lun.configs -// -//import lombok.RequiredArgsConstructor -//import lombok.extern.slf4j.Slf4j -//import org.springframework.batch.core.Job -//import org.springframework.batch.core.Step -//import org.springframework.batch.core.StepContribution -//import org.springframework.batch.core.configuration.DuplicateJobException -//import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing -//import org.springframework.batch.core.configuration.support.DefaultBatchConfiguration -//import org.springframework.batch.core.job.builder.JobBuilder -//import org.springframework.batch.core.repository.JobRepository -//import org.springframework.batch.core.scope.context.ChunkContext -//import org.springframework.batch.core.step.builder.StepBuilder -//import org.springframework.batch.core.step.tasklet.Tasklet -//import org.springframework.batch.repeat.RepeatStatus -//import org.springframework.context.annotation.Bean -//import org.springframework.context.annotation.Configuration -//import org.springframework.transaction.PlatformTransactionManager -//import org.springframework.web.reactive.function.client.WebClient -// -// -//@Configuration -//@RequiredArgsConstructor -//class BatchConfig : DefaultBatchConfiguration() { -// -// @Bean -// @Throws(DuplicateJobException::class) -// fun testJob(jobRepository: JobRepository, transactionManager: PlatformTransactionManager?): Job { -// val job: Job = JobBuilder("testJob", jobRepository!!) -// .start(testStep(jobRepository, transactionManager)) -// .build() -// return job -// } -// -// fun testStep(jobRepository: JobRepository?, transactionManager: PlatformTransactionManager?): Step { -// val step: Step = StepBuilder("testStep", jobRepository!!) -// .tasklet(testTasklet(), transactionManager!!) -// .build() -// return step -// } -// -// fun testTasklet(): Tasklet { -// return (Tasklet { contribution: StepContribution?, chunkContext: ChunkContext? -> -// println("***** hello batch! *****") -// val client0 = WebClient.create() -// val result = client0.get() -// .uri("http://api.weatherapi.com/v1/current.json?key=de574a260b1f474d99955729241909&q=seoul&aqi=no") -// .retrieve() -// .bodyToMono(String::class.java).block() ?: "FAIL" -// -// -// val client = WebClient.create() -// client.get() -// .uri("https://api.telegram.org/bot7934509464:AAE_xUbICxMdywLGnxo7BkeIqA1nVza4P9w/sendMessage?chat_id=71476436&text=${result}") -// .retrieve() -// .bodyToMono(String::class.java).block() ?: "FAIL" -// -// RepeatStatus.FINISHED -// }) -// } -//} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/BumsInterceptor.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/BumsInterceptor.kt index 561efcd..4847417 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/BumsInterceptor.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/BumsInterceptor.kt @@ -8,9 +8,11 @@ import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11 import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey import kr.lunaticbum.back.lun.model.UserManager +import kr.lunaticbum.back.lun.utils.JwtUtil import org.springframework.beans.factory.annotation.Autowired import org.springframework.lang.Nullable import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.web.authentication.RememberMeServices import org.springframework.stereotype.Component import org.springframework.stereotype.Service @@ -18,11 +20,14 @@ import org.springframework.web.servlet.HandlerInterceptor import org.springframework.web.servlet.ModelAndView @Component -class BumsInterceptor : HandlerInterceptor { +class BumsInterceptor( + +) : HandlerInterceptor { @Autowired lateinit var globalEvv : GlobalEnvironment - + @Autowired + lateinit var jwtUtil: JwtUtil val WRITE_PERMISSION_KEY = "PERMISSION" @Throws(Exception::class) @@ -48,28 +53,27 @@ class BumsInterceptor : HandlerInterceptor { handler: Any, @Nullable modelAndView: ModelAndView? ) { - - - modelAndView?.modelMap?.put(EncTypeKey, EncType11) - modelAndView?.modelMap?.put(ApiKeyWordKey,"Def") - - if (modelAndView != null) { + println("modelAndView modelMap size >>> ${modelAndView?.modelMap?.keys?.size}") + // [수정] modelAndView가 null이 아닐 경우에만 로직을 실행하도록 변경합니다. + if (modelAndView != null && modelAndView.hasView()) { modelAndView.modelMap.put(EncTypeKey, EncType11) modelAndView.modelMap.put(ApiKeyWordKey, "Def") - println("modelMap 내용 추가 완료: ${modelAndView.modelMap}") - } else { + + val authentication = SecurityContextHolder.getContext().authentication + val principal = authentication?.principal + + var jwtToken: String? = null + if (principal is UserDetails) { + jwtToken = jwtUtil.generateToken(principal) + } + modelAndView.modelMap.put("jwtToken", jwtToken) + }else { println("modelAndView가 null이라 모델에 값 추가 불가") } + super.postHandle(request, response, handler, modelAndView) } - fun cookieUpdate(cookie: Cookie?) : Cookie? { - cookie?.maxAge = (globalEvv.ACCESS_EXPIRATION / 1000).toInt() - cookie?.domain = "lunaticbum.kr" - cookie?.secure = true - cookie?.path = "/" - return cookie - } } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/JwtGenerator.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/JwtGenerator.kt index e6d8c4d..b9edbfd 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/JwtGenerator.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/JwtGenerator.kt @@ -1,71 +1,71 @@ -package kr.lunaticbum.back.lun.configs - -import io.jsonwebtoken.Jwts -import io.jsonwebtoken.SignatureAlgorithm -import kr.lunaticbum.back.lun.model.User -import lombok.Getter -import lombok.RequiredArgsConstructor -import org.springframework.stereotype.Component -import java.security.Key -import java.util.* -import kotlin.collections.HashMap - - -@Component -class JwtGenerator { - fun generateAccessToken(ACCESS_SECRET: Key?, ACCESS_EXPIRATION: Long, user: User): String { - val now = System.currentTimeMillis() - - return Jwts.builder() - .setHeader(createHeader()) - .setClaims(createClaims(user)) - .setSubject(user.userId) - .setExpiration(Date(now + ACCESS_EXPIRATION)) - .signWith(ACCESS_SECRET, SignatureAlgorithm.HS256) - .compact() - } - - fun generateRefreshToken(REFRESH_SECRET: Key?, REFRESH_EXPIRATION: Long, user: User): String { - val now = System.currentTimeMillis() - - return Jwts.builder() - .setHeader(createHeader()) - .setClaims(createClaims(user)) - .setSubject(user.getIdentifier()) - .setExpiration(Date(now + REFRESH_EXPIRATION)) - .signWith(REFRESH_SECRET, SignatureAlgorithm.HS256) - .compact() - } - - - private fun createHeader(): Map { - val header: MutableMap = HashMap() - header["typ"] = "JWT" - header["alg"] = "HS256" - return header - } - - private fun createClaims(user: User): Map { - val claims: MutableMap = HashMap() - claims["Identifier"] = user.getIdentifier() - claims["Role"] = user.getRole() - return claims - } -} - -@RequiredArgsConstructor -@Getter -enum class TokenStatus { - AUTHENTICATED, - EXPIRED, - INVALID -} - -@RequiredArgsConstructor -@Getter -enum class JwtRule(val value: String) { - JWT_ISSUE_HEADER("Set-Cookie"), - JWT_RESOLVE_HEADER("Cookie"), - ACCESS_PREFIX("access"), - REFRESH_PREFIX("refresh"); -} \ No newline at end of file +//package kr.lunaticbum.back.lun.configs +// +//import io.jsonwebtoken.Jwts +//import io.jsonwebtoken.SignatureAlgorithm +//import kr.lunaticbum.back.lun.model.User +//import lombok.Getter +//import lombok.RequiredArgsConstructor +//import org.springframework.stereotype.Component +//import java.security.Key +//import java.util.* +//import kotlin.collections.HashMap +// +// +//@Component +//class JwtGenerator { +// fun generateAccessToken(ACCESS_SECRET: Key?, ACCESS_EXPIRATION: Long, user: User): String { +// val now = System.currentTimeMillis() +// +// return Jwts.builder() +// .setHeader(createHeader()) +// .setClaims(createClaims(user)) +// .setSubject(user.userId) +// .setExpiration(Date(now + ACCESS_EXPIRATION)) +// .signWith(ACCESS_SECRET, SignatureAlgorithm.HS256) +// .compact() +// } +// +// fun generateRefreshToken(REFRESH_SECRET: Key?, REFRESH_EXPIRATION: Long, user: User): String { +// val now = System.currentTimeMillis() +// +// return Jwts.builder() +// .setHeader(createHeader()) +// .setClaims(createClaims(user)) +// .setSubject(user.getIdentifier()) +// .setExpiration(Date(now + REFRESH_EXPIRATION)) +// .signWith(REFRESH_SECRET, SignatureAlgorithm.HS256) +// .compact() +// } +// +// +// private fun createHeader(): Map { +// val header: MutableMap = HashMap() +// header["typ"] = "JWT" +// header["alg"] = "HS256" +// return header +// } +// +// private fun createClaims(user: User): Map { +// val claims: MutableMap = HashMap() +// claims["Identifier"] = user.getIdentifier() +// claims["Role"] = user.getRole() +// return claims +// } +//} +// +//@RequiredArgsConstructor +//@Getter +//enum class TokenStatus { +// AUTHENTICATED, +// EXPIRED, +// INVALID +//} +// +//@RequiredArgsConstructor +//@Getter +//enum class JwtRule(val value: String) { +// JWT_ISSUE_HEADER("Set-Cookie"), +// JWT_RESOLVE_HEADER("Cookie"), +// ACCESS_PREFIX("access"), +// REFRESH_PREFIX("refresh"); +//} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/RootAppContext.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/RootAppContext.kt deleted file mode 100644 index c53d1ec..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/RootAppContext.kt +++ /dev/null @@ -1,29 +0,0 @@ -package kr.lunaticbum.back.lun.configs - -import org.springframework.context.annotation.Configuration -import org.springframework.data.mongodb.repository.config.EnableMongoRepositories -import org.springframework.scheduling.annotation.EnableAsync - - -@Configuration -@EnableMongoRepositories( basePackages = arrayOf("kr.lunaticbum.back.lun")) -@EnableAsync -class RootAppContext { -// @Bean -// fun mongoClient(): MongoClient { -// return MongoClient("localhost") -// } - -// fun mongoDbFactory(): MongoDbFactory { -// return SimpleMongoDbFactory(mongoClient(), "test") -// } - -// @Bean -// fun mongoTemplate(): MongoTemplate { -// return MongoTemplate(mongoDbFactory()) -// } - -// fun mongoTemplate() :MongoTemplate { -// return MongoTemplate() -// } -} \ 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 0c174a6..60d0784 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt @@ -38,11 +38,19 @@ 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.SecurityContext import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.security.web.context.HttpSessionSecurityContextRepository +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository +import org.springframework.security.web.context.SecurityContextRepository +import org.springframework.security.web.util.matcher.AntPathRequestMatcher +import org.springframework.security.web.util.matcher.NegatedRequestMatcher +import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter import java.security.SignatureException +import java.util.Date @Configuration @EnableWebSecurity @@ -51,11 +59,17 @@ class SecurityConfig( private val jwtUtil: JwtUtil, private val userManager: UserManager, private val bCryptPasswordEncoder: BCryptPasswordEncoder, - private val tokenRepository: MongoPersistentTokenRepository + private val tokenRepository: MongoPersistentTokenRepository, + private val customAccessDeniedHandler: CustomAccessDeniedHandler ) { @Autowired lateinit var logService: LogService + @Bean + fun securityContextRepository(): SecurityContextRepository { + return ApiAndWebSecurityContextRepository() + } + @Bean fun webSecurityCustomizer(): WebSecurityCustomizer { // 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다. @@ -89,18 +103,22 @@ class SecurityConfig( @Bean @Order(1) // API 보안 설정을 먼저 적용 fun apiFilterChain(http: HttpSecurity): SecurityFilterChain { - http + http.securityContext { context -> + context.securityContextRepository(securityContextRepository()) + } .securityMatcher("/api/**") // 이 설정은 /api/ 경로에만 적용됨 .csrf { it.disable() } .cors { it.configurationSource(corsConfigurationSource()) } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음 .authorizeHttpRequests { auth -> auth + .requestMatchers(HttpMethod.GET, "/api/images/**").permitAll() .requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용 .anyRequest().authenticated() // 나머지 API는 인증 필요 } .exceptionHandling { handling -> - handling.authenticationEntryPoint(jwtAuthenticationEntryPoint()) +// handling.authenticationEntryPoint(jwtAuthenticationEntryPoint()) + handling.accessDeniedHandler(accessDeniedHandler2) } // 모든 API 요청 전에 JWT 토큰을 검증하는 필터 추가 .addFilterBefore(JwtAuthenticationFilter(jwtUtil, userManager), UsernamePasswordAuthenticationFilter::class.java) @@ -126,9 +144,12 @@ class SecurityConfig( @Bean @Order(2) // 웹 페이지 보안 설정 fun webFilterChain(http: HttpSecurity): SecurityFilterChain { + http.securityMatcher(NegatedRequestMatcher(AntPathRequestMatcher("/api/**"))) + http.cors { } .csrf { csrf -> csrf.ignoringRequestMatchers( + "/api/**", // <-- 이 줄을 추가하세요! "/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx", "/api/ranks/submit", "/bums/save/loc.api", @@ -140,7 +161,6 @@ class SecurityConfig( .requestMatchers( "/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**" ).permitAll() - // 2. 공개 GET API 및 페이지 = permitAll .requestMatchers(HttpMethod.GET, "/api/images/**", @@ -200,13 +220,28 @@ class SecurityConfig( }.logout { logout -> logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll() }.exceptionHandling { handling -> - handling - .authenticationEntryPoint(unauthorizedEntryPoint) // 인증되지 않은 사용자가 접근 시 - .accessDeniedHandler(accessDeniedHandler) // 인증은 되었으나 권한이 없는 사용자가 접근 시 + handling.accessDeniedHandler(customAccessDeniedHandler) +// .authenticationEntryPoint(unauthorizedEntryPoint) // 인증되지 않은 사용자가 접근 시 +// .accessDeniedHandler(accessDeniedHandler) // 인증은 되었으나 권한이 없는 사용자가 접근 시 } return http.build() } + private val accessDeniedHandler2 = + AccessDeniedHandler { request: HttpServletRequest?, response: HttpServletResponse, accessDeniedException: AccessDeniedException? -> + println("${accessDeniedException?.message }\nSpring security forbidden...") + val fail: ErrorResponse = ErrorResponse.create( Throwable("권한이 없습니다."), + HttpStatus.FORBIDDEN, "${accessDeniedException?.message }\nSpring security forbidden..." + ) + response.status = HttpStatus.FORBIDDEN.value() + val json = ObjectMapper().writeValueAsString(fail) + response.contentType = MediaType.APPLICATION_JSON_VALUE + val writer = response.writer + writer.write(json) + writer.flush() + } + + @Bean fun authenticationManager(http: HttpSecurity): AuthenticationManager { val authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder::class.java) @@ -295,4 +330,64 @@ class JwtAuthenticationFilter( filterChain.doFilter(request, response) println("JWT Token validated") } -} \ No newline at end of file +} + +@Component // 이 클래스를 Spring Bean으로 등록 +class CustomAccessDeniedHandler : AccessDeniedHandler { + + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException + ) { + // 1. 요청(Request) 객체에 오류 정보를 속성(Attribute)으로 담습니다. + request.setAttribute("timestamp", Date()) + request.setAttribute("exception", accessDeniedException) + request.setAttribute("path", request.requestURI) + + // 2. 응답 상태 코드를 403 (Forbidden)으로 설정합니다. + response.status = HttpServletResponse.SC_FORBIDDEN + + // 3. /access-denied 경로로 요청을 전달(Forward)합니다. + // Redirect가 아닌 Forward를 사용해야 request에 담은 정보가 유지됩니다. + val dispatcher = request.getRequestDispatcher("/access-denied") + dispatcher.forward(request, response) + } +} + +class ApiAndWebSecurityContextRepository : SecurityContextRepository { + + // API 요청은 /api/** 패턴에 매칭됩니다. + private val apiRequestMatcher = AntPathRequestMatcher("/api/**") + + // API 요청에 대해서는 세션을 전혀 사용하지 않고, 오직 요청 기간 동안만 SecurityContext를 저장합니다. (완벽한 STATELESS) + private val apiContextRepository = RequestAttributeSecurityContextRepository() + + // 그 외 모든 웹 요청에 대해서는 기본 HttpSession 리포지토리를 사용합니다 (STATEFUL). + private val webContextRepository = HttpSessionSecurityContextRepository() + + override fun loadContext(requestResponseHolder: org.springframework.security.web.context.HttpRequestResponseHolder): SecurityContext { + val request = requestResponseHolder.request + return if (apiRequestMatcher.matches(request)) { + apiContextRepository.loadContext(requestResponseHolder) + } else { + webContextRepository.loadContext(requestResponseHolder) + } + } + + override fun saveContext(context: SecurityContext, request: HttpServletRequest, response: HttpServletResponse) { + if (apiRequestMatcher.matches(request)) { + apiContextRepository.saveContext(context, request, response) + } else { + webContextRepository.saveContext(context, request, response) + } + } + + override fun containsContext(request: HttpServletRequest): Boolean { + return if (apiRequestMatcher.matches(request)) { + apiContextRepository.containsContext(request) + } else { + webContextRepository.containsContext(request) + } + } +} diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/ServletAppContext.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/ServletAppContext.kt deleted file mode 100644 index 2f04091..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/ServletAppContext.kt +++ /dev/null @@ -1,37 +0,0 @@ -package kr.lunaticbum.back.lun.configs - - -//// Spring MVC 프로젝트에 관련된 설정을 하는 클래스 -//@Configuration // Controller 어노테이션이 셋팅되어 있는 클래스를 Controller로 등록한다. -////@ComponentScan("kr.lunaticbum.back.lun.controllers") -//internal class ServletAppContext : WebMvcConfigurer { -// // // Controller의 메서드가 반환하는 jsp의 이름 앞뒤에 경로와 확장자를 붙혀주도록 설정한다. -//// override fun configureViewResolvers(registry: ViewResolverRegistry) { -//// // TODO Auto-generated method stub -//// super.configureViewResolvers(registry) -////// registry.viewResolver { viewName, locale -> } -////// registry.jsp("/WEB-INF/views/", ".jsp") -//// } -//// -//// // 정적 파일의 경로를 매핑한다. -// override fun addResourceHandlers(registry: ResourceHandlerRegistry) { -// // TODO Auto-generated method stub -//// super.addResourceHandlers(registry) -// registry -// .addResourceHandler("/") -//// .addResourceHandler("/**") -// .addResourceLocations("classpath:/META-INF/resources/") -// .addResourceLocations("classpath:/static/") -// .addResourceLocations("classpath:/templates/") -// .addResourceLocations("classpath:/templates/user/") -// .setCacheControl(CacheControl.maxAge(10,TimeUnit.SECONDS)) -// super.addResourceHandlers(registry) -// } -//// @Autowired -//// @Qualifier(value = "authInterceptor") -//// private val authInterceptor: HandlerInterceptor? = null -//// -//// override fun addInterceptors(registry: InterceptorRegistry) { -//// registry.addInterceptor(authInterceptor).addPathPatterns("/**") -//// } -//} \ No newline at end of file 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 d1ac0ae..f8eb701 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt @@ -6,6 +6,7 @@ import com.google.gson.Gson import com.google.gson.JsonParser import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import kr.lunaticbum.back.lun.configs.GlobalEnvironment @@ -873,20 +874,41 @@ 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, + @RequestParam(required = false) category: String?, // 카테고리 파라미터 받기 + @RequestParam(required = false) tag: String?, // 태그 파라미터 받기 @AuthenticationPrincipal userDetails: UserDetails? ): ResultMV { - val vm = ResultMV("content/bookmarks") // 북마크 전용 뷰 템플릿 - val pageable = PageRequest.of(page, 9) // 한 페이지에 9개씩 (3x3 그리드) + val vm = ResultMV("content/bookmarks") + val pageable = PageRequest.of(page, 9) - // 서비스 레이어를 호출하여 현재 사용자 권한에 맞는 북마크 목록을 가져옴 - val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle() + // [수정] 서비스 호출 시 필터 파라미터 전달 + val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable, category, tag).awaitSingle() + + vm.modelMap["bookmarksPage"] = bookmarksPage.map { + + it.contentUrls = arrayListOf().apply { + if (it.thumbnailUrl.isNullOrEmpty() == false) { + add(it.thumbnailUrl!!) + } + if (it.userSelectedImageUrl.isNullOrEmpty() == false) { + add(it.userSelectedImageUrl!!) + } + addAll(it.contentUrls) + } + it + } + + // [추가] 뷰에서 사용할 필터 목록과 현재 선택된 필터 전달 + vm.modelMap["allCategories"] = bookmarkService.findAllDistinctCategories().collectList().awaitSingle() + vm.modelMap["allTags"] = bookmarkService.findAllDistinctTags().collectList().awaitSingle() + vm.modelMap["currentCategory"] = category + vm.modelMap["currentTag"] = tag - vm.modelMap["bookmarksPage"] = bookmarksPage vm.setTitle("저장된 페이지 목록") return vm } @@ -949,7 +971,7 @@ class BookmarkController(private val bookmarkService: WebBookmarkService, @AuthenticationPrincipal userDetails: UserDetails?, pageable: Pageable ): ResponseEntity> { - val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle() + val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable,null,null).awaitSingle() return ResponseEntity.ok(bookmarksPage) } @@ -980,8 +1002,51 @@ class BookmarkController(private val bookmarkService: WebBookmarkService, .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, +) { + + @GetMapping("/categories") + fun getBookmarkCategories(): Mono> { + return bookmarkService.findAllDistinctCategories().collectList() + } + + // [신규] 모든 북마크의 고유 태그 목록을 반환하는 API + @GetMapping("/tags") + fun getBookmarkTags(): Mono> { + return bookmarkService.findAllDistinctTags().collectList() + } + + + @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 // Spring이 ?page=X&size=Y 파라미터를 자동으로 Pageable 객체로 변환해 줌 + ): ResponseEntity> { + // 기존 서비스 메서드를 그대로 사용하여 사용자 권한에 맞는 북마크 목록을 가져옴 + val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable,null,null).awaitSingle() + return ResponseEntity.ok(bookmarksPage) + } + data class BookmarkDataDto( val url: String, + val bookmarkType : String, val userComment: String?, val visibility: String? ) @@ -1000,7 +1065,7 @@ class BookmarkController(private val bookmarkService: WebBookmarkService, val uniqueFilename = "${UUID.randomUUID()}_${imageFile.originalFilename}" val targetPath = Paths.get(uploadPath, uniqueFilename) try { - Files.createDirectories(targetPath.parent) +// Files.createDirectories(targetPath.parent) imageFile.transferTo(targetPath.toFile()) } catch (e: Exception) { return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()) @@ -1025,47 +1090,12 @@ class BookmarkController(private val bookmarkService: WebBookmarkService, .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 문자열로 받음 + // BlogController.kt의 BookmarkApiController 내부 + @PostMapping("/with-content", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun saveBookmarkWithContent( + @RequestPart("files") files: List, // [수정] 단일 파일 -> 파일 목록 + @RequestPart("bookmarkData") bookmarkDataJson: String, @AuthenticationPrincipal user: UserDetails? ): Mono> { logService.log("uploadPath >>> ${uploadPath}") @@ -1073,23 +1103,22 @@ class BookmarkApiController( return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()) } + // 1. 전달받은 파일들을 서버에 저장하고, 각 파일의 경로 목록을 생성합니다. + val savedFilePaths = files.map { file -> + val uniqueFilename = "${UUID.randomUUID()}_${file.originalFilename}" + val targetPath = Paths.get(uploadPath, uniqueFilename) + try { + file.transferTo(targetPath.toFile()) + "/api/images/$uniqueFilename" // 저장 성공 시 반환될 경로 + } catch (e: Exception) { + e.printStackTrace() + // 에러 발생 시 null 반환 (이후 filterNotNull로 걸러냄) + null + } + }.filterNotNull() // 저장에 실패한 파일(null)은 목록에서 제외 - // 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") + if (savedFilePaths.isEmpty()) { + // 모든 파일 저장에 실패한 경우 return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()) } @@ -1100,18 +1129,157 @@ class BookmarkApiController( val newBookmark = WebBookmark( userId = user.username, url = bookmarkData.url, + // [수정] bookmarkType을 DTO에서 받아오도록 변경 (예: IMAGE, VIDEO) + bookmarkType = bookmarkData.bookmarkType ?: BookmarkType.IMAGE.name, + contentUrls = savedFilePaths, // [수정] 저장된 파일 경로 목록을 contentUrls에 할당 userComment = bookmarkData.userComment, visibility = bookmarkData.visibility ?: "PRIVATE", - metadataStatus = "PENDING", - // 저장된 이미지의 서버 URL을 저장 - userSelectedImageUrl = "/api/images/$uniqueFilename" + metadataStatus = "COMPLETED", // 파일이 직접 업로드되었으므로 메타데이터 처리는 완료됨 + thumbnailUrl = savedFilePaths.first() // 첫 번째 이미지를 대표 썸네일로 사용 ) - println("newBookmark ${newBookmark}") + // 4. 북마크 정보 DB에 저장 return bookmarkService.saveBookmark(newBookmark) - .map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }.apply { - println("OK") - } + .map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) } + } + + /** + * [수정] ID로 단일 북마크를 가져옵니다. + * 이 엔드포인트가 누락되어 401/404 오류가 발생했습니다. + */ + @GetMapping("/{id}") + suspend fun getBookmarkById( + @PathVariable id: String, + @AuthenticationPrincipal userDetails: UserDetails? + ): ResponseEntity { + val bookmark = bookmarkService.findById(id).awaitSingleOrNull() + ?: return ResponseEntity.notFound().build() + + // 현재 사용자가 이 북마크를 볼 권한이 있는지 확인합니다. + val isOwner = userDetails?.username == bookmark.userId + val canView = when (bookmark.visibility) { + Visibility.PUBLIC.name -> true + Visibility.MEMBERS.name -> userDetails != null + Visibility.PRIVATE.name -> isOwner + else -> false + } + + return if (canView) { + ResponseEntity.ok(bookmark) + } else { + ResponseEntity.status(HttpStatus.FORBIDDEN).build() + } + } + + data class BookmarkUpdateRequest( + val title: String?, + val userComment: String?, + val visibility: String?, + val category: String?, + val tags: List? + ) + + data class TagResponse(val resultCode: Int = 0, val resultMsg: String = "OK", val tags: List) + + + + @DeleteMapping("/{id}") + suspend fun deleteBookmark( + @PathVariable id: String, + @AuthenticationPrincipal userDetails: UserDetails? + ): ResponseEntity> { // [수정] 반환 타입 변경 + logService.log("북마크 삭제 요청: ID=$id, 사용자=${userDetails?.username}") + + // 1. 사용자 인증 정보 확인 + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(mapOf("message" to "인증이 필요합니다.")) + } + + // 2. 북마크 존재 여부 확인 + val bookmark = bookmarkService.findById(id).awaitSingleOrNull() + if (bookmark == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(mapOf("message" to "삭제할 북마크를 찾을 수 없습니다: ID=$id")) + } + + // 3. 소유권 확인 + if (userDetails.username != bookmark.userId) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(mapOf("message" to "이 북마크를 삭제할 권한이 없습니다.")) + } + + // 4. 삭제 실행 + return try { + bookmarkService.deleteBookmark(id).awaitFirstOrNull() + logService.log("DB 삭제 성공: ID=$id") + ResponseEntity.ok(mapOf("message" to "북마크가 성공적으로 삭제되었습니다.", "id" to id)) + } catch (e: Exception) { + logService.log("DB 삭제 중 예외 발생: ID=$id, 오류=${e.message}") + ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(mapOf("message" to "북마크 삭제 중 서버 오류가 발생했습니다.")) + } + } + + @PutMapping("/{id}") + suspend fun updateBookmark( + @PathVariable id: String, + @RequestBody request: BookmarkUpdateRequest, + @AuthenticationPrincipal userDetails: UserDetails? + ): ResponseEntity<*> { // [수정] 반환 타입 변경 + logService.log("북마크 업데이트 요청: ID=$id, 사용자=${userDetails?.username}") + + // 1. 사용자 인증 정보 확인 + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(mapOf("message" to "인증이 필요합니다.")) + } + + // 2. 북마크 존재 여부 확인 + val existingBookmark = bookmarkService.findById(id).awaitSingleOrNull() + if (existingBookmark == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(mapOf("message" to "수정할 북마크를 찾을 수 없습니다: ID=$id")) + } + + // 3. 소유권 확인 + if (userDetails.username != existingBookmark.userId) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(mapOf("message" to "이 북마크를 수정할 권한이 없습니다.")) + } + + // 4. 업데이트 실행 + val updatedBookmark = existingBookmark.copy( + title = request.title ?: existingBookmark.title, + userComment = request.userComment, + visibility = request.visibility ?: existingBookmark.visibility, + category = request.category, + tags = request.tags ?: existingBookmark.tags + ) + + return try { + val savedBookmark = bookmarkService.saveBookmark(updatedBookmark).awaitSingle() + logService.log("DB 업데이트 성공: ID=$id") + ResponseEntity.ok(savedBookmark) + } catch (e: Exception) { + logService.log("DB 업데이트 중 예외 발생: ID=$id, 오류=${e.message}") + ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(mapOf("message" to "북마크 업데이트 중 서버 오류가 발생했습니다.")) + } } } + + +@Controller +class CustomErrorController { + + // SecurityConfig에서 지정한 "/access-denied" 경로를 처리합니다. + @GetMapping("/access-denied") + fun accessDeniedPage(model: org.springframework.ui.Model): String { + model.addAttribute("statusCode", "403") + model.addAttribute("errorMessage", "이 페이지에 접근할 권한이 없습니다.") + model.addAttribute("errorDescription", "요청하신 리소스에 대한 접근 권한이 부족합니다. 관리자에게 문의하거나 다른 계정으로 로그인해 주세요.") + return "content/error_page" // 보여줄 HTML 파일의 경로 + } +} \ No newline at end of file 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 efa1f3d..dcf3090 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt @@ -8,7 +8,6 @@ import kr.lunaticbum.back.lun.configs.GlobalEnvironment import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11 import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey -import kr.lunaticbum.back.lun.configs.JwtRule import kr.lunaticbum.back.lun.model.* import kr.lunaticbum.back.lun.utils.JwtUtil import kr.lunaticbum.back.lun.utils.LogService @@ -50,8 +49,8 @@ class UserController( private val gameRankService: GameRankService, // [신규 추가] GameRankService 의존성 주입 private val messageService: MessageService, private val webBookmarkService: WebBookmarkService, - private val imageMetaService: ImageMetaService - + private val imageMetaService: ImageMetaService, + private val jwtUtil: JwtUtil ) { @@ -133,6 +132,10 @@ class UserController( val principal = authResult?.principal if (principal is UserDetails) { + val token = jwtUtil.generateToken(principal) + loginResult.token = token // 2. 응답 객체에 토큰 추가 + + println("target.remeberMe >>> ${target.rememberMe}") loginResult.rememberMe = target.rememberMe if (target.rememberMe == true) { @@ -266,6 +269,14 @@ class UserController( val myPosts = postManager.findPostsByWriter(username, PageRequest.of(0, 10)).collectList().block() vm.modelMap["myPosts"] = myPosts ?: emptyList() + // 사용자가 저장한 모든 북마크 목록을 가져옵니다. + val myBookmarks = webBookmarkService.getBookmarksForUser(username) // 1. 모든 북마크를 Flux로 가져옴 + .collectList() // 2. Flux 스트림을 Mono>으로 변환 + .block() // 3. 최종적으로 List으로 변환 + + // 모델에 "myBookmarks" 라는 키로 저장된 북마크 리스트를 추가합니다. + vm.modelMap["myBookmarks"] = myBookmarks ?: emptyList() + // 3. 내가 쓴 댓글 목록 조회 (최신 10개) val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block() vm.modelMap["myComments"] = myComments ?: emptyList() diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/BumsPrivate.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/BumsPrivate.kt deleted file mode 100644 index 32902fb..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/BumsPrivate.kt +++ /dev/null @@ -1,28 +0,0 @@ -//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 deleted file mode 100644 index ed933ba..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt +++ /dev/null @@ -1,150 +0,0 @@ -package kr.lunaticbum.back.lun.model - -import org.springframework.data.annotation.Id -import org.springframework.data.mongodb.core.mapping.Document -import java.time.Instant -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 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 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 f47e818..cdd4e1b 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt @@ -1055,15 +1055,22 @@ enum class MetadataStatus { FAILED // 처리 실패 } - +enum class BookmarkType { + URL, // 기존 웹 페이지 링크 + IMAGE, // 하나 이상의 이미지 + VIDEO // 하나 이상의 비디오 +} @Document(collection = "WebBookmark") data class WebBookmark( @BsonId @BsonRepresentation(BsonType.OBJECT_ID) var id: String? = null, - var userId: String, // 누가 저장했는지 - var url: String, // 원본 페이지 URL + var url: String?, // 원본 페이지 URL + // [신규] 북마크 타입 (URL, IMAGE, VIDEO 등) + var bookmarkType: String = BookmarkType.URL.name, + // [신규] 콘텐츠 URL 목록 (웹페이지는 1개, 이미지는 여러 개 가능) + var contentUrls: List = emptyList(), var title: String? = null, // 페이지 제목 var description: String? = null, // 페이지 요약 (메타 태그) var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그) @@ -1076,8 +1083,32 @@ data class WebBookmark( var voteCount: Long = 0, var unlikeCount: Long = 0, var userSelectedImageUrl: String? = null, - var metadataStatus: String = MetadataStatus.PENDING.name -) + var metadataStatus: String = MetadataStatus.PENDING.name, + + // [추가] 카테고리 필드 (하나만 가질 수 있도록 String으로 설정) + var category: String? = null +) { + + /** + * [이 부분을 추가하세요] + * 화면에 표시할 최종 이미지 URL을 계산하는 프로퍼티입니다. + * @get:BsonIgnore 어노테이션으로 이 필드는 DB에 저장되지 않습니다. + */ + @get:BsonIgnore + val displayImageUrl: String + get() { + return when { + // 1순위: 사용자가 선택한 이미지 + !userSelectedImageUrl.isNullOrBlank() -> userSelectedImageUrl!! + // 2순위: 자동 추출된 썸네일 + !thumbnailUrl.isNullOrBlank() -> thumbnailUrl!! + // 3순위: 콘텐츠 URL 목록의 첫 번째 이미지 + contentUrls.isNotEmpty() -> contentUrls.first() + // 4순위: 기본 이미지 + else -> "/images/pic01.jpg" + } + } +} @Repository interface WebBookmarkRepository : ReactiveMongoRepository { @@ -1086,6 +1117,13 @@ interface WebBookmarkRepository : ReactiveMongoRepository { fun countByVisibilityIn(visibilities: List): Mono fun findByMetadataStatus(status: String): Flux + + // [추가] 필터링을 위한 고유 카테고리 및 태그 목록 조회 (이 위치로 이동) + @Aggregation("{ \$unwind: '\$tags' }", "{ \$group: { _id: '\$tags' } }") + fun findDistinctTags(): Flux> + + @Aggregation("{ \$group: { _id: '\$category' } }") + fun findDistinctCategories(): Flux> } @Service @@ -1093,6 +1131,25 @@ class WebBookmarkService(private val repository: WebBookmarkRepository, private val reactiveMongoTemplate: ReactiveMongoTemplate // [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다. ) { + // [이 메소드를 추가하세요] + fun findById(id: String): Mono { + return repository.findById(id) + } + // [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지 + fun findAllDistinctCategories(): Flux { + return repository.findDistinctCategories() + .flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) } + .filter { it.isNotBlank() } + } + + // [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지 + fun findAllDistinctTags(): Flux { + return repository.findDistinctTags() + .flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) } + .filter { it.isNotBlank() } + } + + fun getBookmarksForUser(userId: String): Flux { return repository.findByUserIdOrderBySavedAtDesc(userId) } @@ -1107,24 +1164,37 @@ class WebBookmarkService(private val repository: WebBookmarkRepository, return repository.deleteById(id) } - // [신규 추가] 사용자의 권한에 따라 볼 수 있는 북마크 목록을 페이지네이션으로 조회 - fun getVisibleBookmarks(userDetails: UserDetails?, pageable: Pageable): Mono> { + // [수정] getVisibleBookmarks 메소드에 필터링 기능 추가 + fun getVisibleBookmarks( + userDetails: UserDetails?, + pageable: Pageable, + category: String?, // 카테고리 파라미터 추가 + tag: String? // 태그 파라미터 추가 + ): 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) + // 동적 쿼리 생성 시작 + val query = Query(Criteria.where("visibility").`in`(visibleScopes)) + .with(Sort.by(Sort.Direction.DESC, "savedAt")) // <-- 이 줄을 추가하세요. + .with(pageable) + + // 카테고리 조건 추가 + if (!category.isNullOrBlank()) { + query.addCriteria(Criteria.where("category").`is`(category)) + } + + // 태그 조건 추가 (tags 배열에 해당 태그가 포함되어 있는지 확인) + if (!tag.isNullOrBlank()) { + query.addCriteria(Criteria.where("tags").`in`(tag)) + } + + // 데이터 조회 및 카운트 + val bookmarks = reactiveMongoTemplate.find(query, WebBookmark::class.java).collectList() + val totalCount = reactiveMongoTemplate.count(Query.of(query).limit(-1).skip(-1), WebBookmark::class.java) - // Mono.zip을 사용하여 두 비동기 작업(목록 조회, 카운트)을 병렬로 실행 return Mono.zip(bookmarks, totalCount).map { tuple -> PageImpl(tuple.t1, pageable, tuple.t2) } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/ResponceResult.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/ResponceResult.kt index 601f2e5..f9abd23 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/ResponceResult.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/ResponceResult.kt @@ -17,6 +17,8 @@ open class PostsResult : BaseResult() { @Getter open class LoginResult : ResponceResult() { var rememberMe: Boolean? = null + + var token: String? = null // [추가] JWT 토큰을 담을 필드 } @Getter 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 e648f0c..493441c 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt @@ -20,7 +20,7 @@ import org.springframework.stereotype.Service import reactor.core.publisher.Flux import reactor.core.publisher.Mono import java.time.Duration - +import com.fasterxml.jackson.annotation.JsonProperty @Data @NoArgsConstructor @@ -32,6 +32,7 @@ data class User ( @BsonRepresentation(BsonType.OBJECT_ID) var userId: String? = null, + @Id var user_id: String? = null, var user_pw: String? = null, diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/BookmarkProcessorService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/BookmarkProcessorService.kt index c5751c6..415137e 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/service/BookmarkProcessorService.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/BookmarkProcessorService.kt @@ -1,6 +1,7 @@ package kr.lunaticbum.back.lun.service +import kr.lunaticbum.back.lun.model.BookmarkType import kr.lunaticbum.back.lun.model.MetadataStatus import kr.lunaticbum.back.lun.model.WebBookmark import kr.lunaticbum.back.lun.model.WebBookmarkRepository @@ -39,20 +40,23 @@ class BookmarkProcessorService( return Mono.fromCallable { // Jsoup 호출은 블로킹(blocking) 작업이므로 fromCallable로 감싸고 // 별도 스레드에서 실행되도록 subscribeOn을 사용 - logService.log("Fetching metadata for: ${bookmark.url}") - val doc = Jsoup.connect(bookmark.url).timeout(10000).get() // 10초 타임아웃 + if(bookmark.bookmarkType.equals(BookmarkType.URL.name, ignoreCase = true)){ + logService.log("Fetching metadata for: ${bookmark.contentUrls.first()}") + val doc = Jsoup.connect(bookmark.contentUrls.first()).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") + // 메타데이터 추출 + 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.title = title + bookmark.description = description + bookmark.thumbnailUrl = imageUrl + bookmark.metadataStatus = MetadataStatus.COMPLETED.name // 상태를 COMPLETED로 변경 + } bookmark + } .subscribeOn(Schedulers.boundedElastic()) .flatMap { updatedBookmark -> diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index a3e8fc1..b93131a 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -103,4 +103,5 @@ spring.webflux.response-timeout=60s 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 +jwt.expiration=86400000 +logging.level.org.springframework.security=DEBUG \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 316221e..e94640d 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -103,4 +103,5 @@ spring.webflux.response-timeout=60s 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 +jwt.expiration=86400000 +logging.level.org.springframework.security=DEBUG \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6d1aa5e..ede3c59 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -104,4 +104,5 @@ 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 +jwt.expiration=86400000 +logging.level.org.springframework.security=DEBUG \ 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 d02db0a..1546f13 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -1777,4 +1777,135 @@ function openBookmarkInIframe(url, title) { // 팝업과 오버레이 표시 overlay.style.display = 'block'; popup.style.display = 'block'; +} + +// 팝업과 폼 필드를 연결하기 위한 전역 변수 +let bookmarkPopupTargets = { + displayId: null, + inputId: null +}; +let stagedBookmarkCategory = ''; +let stagedBookmarkTags = []; + +/** + * 북마크 카테고리 팝업을 여는 함수 + * @param {string} displayId - 선택된 카테고리를 보여줄 div의 ID + * @param {string} inputId - 실제 값을 저장할 hidden input의 ID + */ +async function openBookmarkCategoryPopup(displayId, inputId) { + bookmarkPopupTargets = { displayId, inputId }; // 현재 작업 대상 필드를 저장 + + const currentCategory = document.getElementById(inputId).value; + stagedBookmarkCategory = currentCategory || ''; + renderStagedBookmarkCategory(); + + // 기존 카테고리 목록 불러오기 + const listEl = document.getElementById('bookmark-category-list'); + listEl.innerHTML = '로딩...'; + try { + const response = await fetch('/api/bookmarks/categories',{ + headers: { + 'Authorization': `Bearer ${serverData.token}` // 헤더에 토큰 추가 + }, + }); + const categories = await response.json(); + listEl.innerHTML = ''; + categories.forEach(cat => { + const tagEl = document.createElement('span'); + tagEl.className = 'tag-item'; + tagEl.textContent = cat; + tagEl.onclick = () => { + stagedBookmarkCategory = cat; + renderStagedBookmarkCategory(); + }; + listEl.appendChild(tagEl); + }); + } catch (e) { + listEl.innerHTML = '카테고리를 불러오는데 실패했습니다.'; + } + + const dummyEl = document.createElement('div'); + dummyEl.setAttribute('to', '#bookmark-category-popup'); + openPopup(dummyEl); +} +document.getElementById('new-bookmark-category-input')?.addEventListener('keyup', e => { + if (e.key === 'Enter') { + stagedBookmarkCategory = e.target.value.trim(); + renderStagedBookmarkCategory(); + e.target.value = ''; + } +}); +function renderStagedBookmarkCategory() { + const area = document.getElementById('selected-bookmark-category-area'); + area.innerHTML = stagedBookmarkCategory ? `${stagedBookmarkCategory} X` : '선택된 카테고리 없음'; +} +function applyBookmarkCategory() { + document.getElementById(bookmarkPopupTargets.inputId).value = stagedBookmarkCategory; + document.getElementById(bookmarkPopupTargets.displayId).innerHTML = stagedBookmarkCategory ? `${stagedBookmarkCategory}` : '카테고리 선택'; + closePopup(); +} + +/** + * 북마크 태그 팝업을 여는 함수 + * @param {string} displayId - 선택된 태그를 보여줄 div의 ID + * @param {string} inputId - 실제 값을 저장할 hidden input의 ID + */ +async function openBookmarkTagPopup(displayId, inputId) { + bookmarkPopupTargets = { displayId, inputId }; + + const currentTags = document.getElementById(inputId).value; + stagedBookmarkTags = currentTags ? currentTags.split(',').map(t => t.trim()) : []; + renderStagedBookmarkTags(); + + // 기존 태그 목록 불러오기 + const listEl = document.getElementById('bookmark-tag-list'); + listEl.innerHTML = '로딩...'; + try { + const response = await fetch('/api/bookmarks/tags',{ + headers: { + 'Authorization': `Bearer ${serverData.token}` // 헤더에 토큰 추가 + }, + }); + const tags = await response.json(); + listEl.innerHTML = ''; + tags.forEach(tag => { + const tagEl = document.createElement('span'); + tagEl.className = 'tag-item'; + tagEl.textContent = '#' + tag; + tagEl.onclick = () => addStagedBookmarkTag(tag); + listEl.appendChild(tagEl); + }); + } catch (e) { + listEl.innerHTML = '태그를 불러오는데 실패했습니다.'; + } + + const dummyEl = document.createElement('div'); + dummyEl.setAttribute('to', '#bookmark-tag-popup'); + openPopup(dummyEl); +} +document.getElementById('new-bookmark-tag-input')?.addEventListener('keyup', e => { + if (e.key === 'Enter') { + addStagedBookmarkTag(e.target.value.trim()); + e.target.value = ''; + } +}); +function addStagedBookmarkTag(tag) { + if (tag && !stagedBookmarkTags.includes(tag)) { + stagedBookmarkTags.push(tag); + renderStagedBookmarkTags(); + } +} +function removeStagedBookmarkTag(index) { + stagedBookmarkTags.splice(index, 1); + renderStagedBookmarkTags(); +} +function renderStagedBookmarkTags() { + const area = document.getElementById('selected-bookmark-tags-area'); + area.innerHTML = stagedBookmarkTags.map((tag, i) => `#${tag} X`).join(' ') || '선택된 태그 없음'; +} +function applyBookmarkTags() { + const tagsString = stagedBookmarkTags.join(','); + document.getElementById(bookmarkPopupTargets.inputId).value = tagsString; + document.getElementById(bookmarkPopupTargets.displayId).innerHTML = stagedBookmarkTags.map(tag => `#${tag}`).join(' ') || '태그 선택'; + closePopup(); } \ No newline at end of file diff --git a/src/main/resources/templates/content/bookmarks.html b/src/main/resources/templates/content/bookmarks.html index b1711e2..15a9e39 100644 --- a/src/main/resources/templates/content/bookmarks.html +++ b/src/main/resources/templates/content/bookmarks.html @@ -2,9 +2,35 @@ Bookmarks + + + +
@@ -12,71 +38,103 @@

Bookmarks

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

+
+
+ 카테고리: + 전체 + +
+
+ 태그: + +
+
-
-
-

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

-
-
-
- - Thumbnail - -
-
-

북마크 제목

-

-
-

+
+
+
+
-
-
- - +
+
+ Bookmark Image
- 댓글 보기 - -
- by -
-
-
+
+
+
+

북마크 제목

+

+
+

+
+ +
+
+ + + +
+ + +
+
+
+
+
+
+
- +
+

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

+
diff --git a/src/main/resources/templates/content/error_page.html b/src/main/resources/templates/content/error_page.html new file mode 100644 index 0000000..f8b9636 --- /dev/null +++ b/src/main/resources/templates/content/error_page.html @@ -0,0 +1,25 @@ + + + + +
+
+
+

오류가 발생했습니다

+

오류 메시지

+
+ +
+

+ 오류에 대한 상세 설명입니다. 이 페이지는 접근이 금지되었거나, 요청 처리 중 문제가 발생했을 수 있습니다. +

+
+ + 홈으로 돌아가기 +
+
+
+ + \ 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 e667bb3..d3f48be 100644 --- a/src/main/resources/templates/content/user/my_info.html +++ b/src/main/resources/templates/content/user/my_info.html @@ -155,6 +155,17 @@ +
+ 카테고리 +
카테고리 선택
+
+ + +
+ 태그 +
태그 선택
+
+ @@ -162,15 +173,26 @@

저장된 목록

-
+
+

저장한 페이지가 없습니다.

+
+ +
- + + Bookmark Thumbnail +
-

카드 제목

-

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

+

카드 제목

+

사용자 코멘트

-

원본 페이지 설명...

+

원본 페이지 설명...

+ +
+ + +
@@ -497,6 +519,8 @@ // [수정] 선택된 공개 범위(visibility) 값을 읽어오는 코드 추가 const visibility = document.querySelector('input[name="visibility"]:checked').value; + const category = document.getElementById('new-bookmark-category').value; + const tags = document.getElementById('new-bookmark-tags').value; const bookmarkData = { url: urlInput.value.trim(), @@ -506,7 +530,9 @@ thumbnailUrl: ogData.thumbnailUrl, userComment: comment, // [수정] bookmarkData 객체에 visibility 프로퍼티 추가 - visibility: visibility + visibility: visibility, + category: category, + tags: tags }; if (!bookmarkData.url) { @@ -532,6 +558,119 @@ } }); }); + + /** + * [수정] 북마크 수정 팝업을 열고 데이터를 채우는 함수 + */ + async function openEditBookmarkModal(bookmarkId) { + try { + // /api/** 경로는 JWT 인증 헤더(Authorization)가 필요합니다. + // 이는 전역 fetch 인터셉터 등에서 처리된다고 가정합니다. + const response = await fetch(`/api/bookmarks/${bookmarkId}`,{ + headers: { + 'Authorization': `Bearer ${serverData.token}` // 헤더에 토큰 추가 + }, + }); + if (!response.ok) { + throw new Error(`서버 응답: ${response.status}`); + } + const bookmark = await response.json(); + + // 이 부분은 모달 요소가 없어서 실패했었습니다. + // 이제 default_layout.html에 요소가 추가되었습니다. + document.getElementById('edit-bookmark-id').value = bookmark.id; + document.getElementById('edit-bookmark-title').value = bookmark.title || ''; + document.getElementById('edit-bookmark-comment').value = bookmark.userComment || ''; + document.getElementById('edit-bookmark-visibility').value = bookmark.visibility || 'PRIVATE'; + + document.getElementById('edit-bookmark-category-display').innerHTML = bookmark.category ? `${bookmark.category}` : '카테고리 선택'; + document.getElementById('edit-bookmark-category').value = bookmark.category || ''; + + document.getElementById('edit-bookmark-tags-display').innerHTML = (bookmark.tags || []).map(t => `#${t}`).join(' ') || '태그 선택'; + document.getElementById('edit-bookmark-tags').value = (bookmark.tags || []).join(','); + + // 공통 openPopup 함수 사용 + const dummyEl = document.createElement('div'); + dummyEl.setAttribute('to', '#bookmark-edit-popup'); + openPopup(dummyEl); + + } catch(error) { + console.error("수정 모달 열기 실패:", error); + showAlert('오류', '북마크 정보를 불러오는 데 실패했습니다. 로그인 상태를 확인해주세요.', 'error'); + } + } + /** + * [수정] 북마크 수정 내용을 서버에 제출하는 함수 + */ + async function submitBookmarkUpdate() { + const bookmarkId = document.getElementById('edit-bookmark-id').value; + const tagsValue = document.getElementById('edit-bookmark-tags').value; + + const updatedData = { + title: document.getElementById('edit-bookmark-title').value, + userComment: document.getElementById('edit-bookmark-comment').value, + visibility: document.getElementById('edit-bookmark-visibility').value, + category: document.getElementById('edit-bookmark-category').value, + tags: tagsValue ? tagsValue.split(',').map(t => t.trim()).filter(t => t) : [] + }; + + try { + const response = await fetch(`/api/bookmarks/${bookmarkId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${serverData.token}` // 헤더에 토큰 추가 + }, + body: JSON.stringify(updatedData) + }); + + if (response.ok) { + showAlert('성공', '북마크 정보가 수정되었습니다.', 'success'); + location.reload(); // 변경 사항을 확인하기 위해 페이지 새로고침 + } else { + const errorData = await response.text(); + showAlert('오류', `수정에 실패했습니다: ${errorData}`, 'error'); + } + } catch (error) { + console.error('Error updating bookmark:', error); + showAlert('오류', '네트워크 오류로 수정에 실패했습니다.', 'error'); + } + } + + /** + * [신규] 북마크를 삭제하는 함수. + * 'deleteBookmark is not defined' 오류를 해결합니다. + */ + async function deleteBookmark(bookmarkId) { + // common.js의 공통 확인 모달 사용 + const confirmed = await showConfirm('삭제 확인', '이 북마크를 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.'); + if (confirmed) { + try { + // /api/** 경로는 상태가 없는(stateless) JWT 인증을 사용하므로 CSRF 토큰이 필요 없습니다. + const response = await fetch(`/api/bookmarks/${bookmarkId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${serverData.token}` // 헤더에 토큰 추가 + }, + }); + + if (response.ok) { + showAlert('성공', '북마크가 삭제되었습니다.', 'success'); + // 페이지에서 삭제된 항목 제거 + document.getElementById(`bookmark-row-${bookmarkId}`).remove(); + } else { + const errorData = await response.text(); + showAlert('오류', `삭제에 실패했습니다: ${errorData}`, 'error'); + } + } catch (error) { + console.error('Error deleting bookmark:', error); + showAlert('오류', '네트워크 오류로 삭제에 실패했습니다.', 'error'); + } + } + } + + + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/includes.html b/src/main/resources/templates/fragments/includes.html index 30e490f..34a2a08 100644 --- a/src/main/resources/templates/fragments/includes.html +++ b/src/main/resources/templates/fragments/includes.html @@ -55,7 +55,9 @@ unlikeCount: [[${srcPost?.unlikeCount ?: 0}]], // --- Page-specific (not model data) --- enc: /*[[${enc ?: ''}]]*/, - keyword: /*[[${keyword ?: ''}]]*/ + keyword: /*[[${keyword ?: ''}]]*/, + // --- [핵심 추가] --- + token: /*[[${jwtToken}]]*/ }; diff --git a/src/main/resources/templates/layout/default_layout.html b/src/main/resources/templates/layout/default_layout.html index 1912304..fb38589 100644 --- a/src/main/resources/templates/layout/default_layout.html +++ b/src/main/resources/templates/layout/default_layout.html @@ -73,6 +73,78 @@
+ +
+
+
+

북마크 수정

+ + + + + + + + + + + +
+ 카테고리 +
카테고리 선택
+
+ + +
+ 태그 +
태그 선택
+
+ + +
+ + 취소 +
+
+
+
+ +
+
+
+

카테고리 선택

+
+
+
+ +
+ + 취소 +
+
+
+
+ +
+
+
+

태그 선택

+
+
+
+ +
+ + 취소 +
+
+
+
+