This commit is contained in:
lunaticbum 2025-09-19 16:32:24 +09:00
parent 5e0db4ff03
commit 4b652c4df5
25 changed files with 1166 additions and 730 deletions

View File

@ -1,117 +1,117 @@
//import com.google.gson.Gson import com.google.gson.Gson
//import okhttp3.* import okhttp3.*
//import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
//import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
//import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
//import java.io.File import java.io.File
//import java.io.IOException import java.io.IOException
//
//// Gson 파싱을 위한 데이터 클래스 // Gson 파싱을 위한 데이터 클래스
//data class LoginRequest(val userId: String, val userPw: String) data class LoginRequest(val userId: String, val userPw: String)
//data class LoginResponse(val token: String?) data class LoginResponse(val token: String?)
//
///** /**
// * API 통합 테스트를 실행하는 메인 함수입니다. * API 통합 테스트를 실행하는 메인 함수입니다.
// * IDE에서 직접 실행(▶)할 수 있습니다. * IDE에서 직접 실행() 있습니다.
// */ */
//fun main() { fun main() {
// val tester = ApiIntegrationTest() val tester = ApiIntegrationTest()
// tester.runBookmarkTest() tester.runBookmarkTest()
//} }
//
//class ApiIntegrationTest { class ApiIntegrationTest {
//
// private val client = OkHttpClient() private val client = OkHttpClient()
// private val gson = Gson() private val gson = Gson()
// private val jsonMediaType = "application/json; charset=utf-8".toMediaType() private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
//
// // --- 테스트 환경 설정 --- // --- 테스트 환경 설정 ---
// private val baseUrl = "http://localhost:443" private val baseUrl = "http://localhost:443"
// private val testUserId = "lunaticbum" private val testUserId = "lunaticbum"
// private val testUserPw = "VioPup*383" private val testUserPw = "VioPup*383"
// private val imageToUpload = File("test_image.jpg") // 프로젝트 루트에 있는 이미지 파일 private val imageToUpload = File("test_image.jpg") // 프로젝트 루트에 있는 이미지 파일
//
// /** /**
// * 로그인 API를 호출하여 JWT 토큰을 반환합니다. * 로그인 API를 호출하여 JWT 토큰을 반환합니다.
// */ */
// private fun loginAndGetToken(): String? { private fun loginAndGetToken(): String? {
// println("1. 로그인을 시도합니다...") println("1. 로그인을 시도합니다...")
//
// val loginRequest = LoginRequest(userId = testUserId, userPw = testUserPw) val loginRequest = LoginRequest(userId = testUserId, userPw = testUserPw)
// val requestBody = gson.toJson(loginRequest).toRequestBody(jsonMediaType) val requestBody = gson.toJson(loginRequest).toRequestBody(jsonMediaType)
//
// val request = Request.Builder() val request = Request.Builder()
// .url("$baseUrl/api/auth/login") .url("$baseUrl/api/auth/login")
// .post(requestBody) .post(requestBody)
// .build() .build()
//
// try { try {
// client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
// if (!response.isSuccessful) { if (!response.isSuccessful) {
// println("❌ 로그인 실패: ${response.code} - ${response.body?.string()}") println("❌ 로그인 실패: ${response.code} - ${response.body?.string()}")
// return null return null
// } }
// val responseBody = response.body?.string() val responseBody = response.body?.string()
// val loginResponse = gson.fromJson(responseBody, LoginResponse::class.java) val loginResponse = gson.fromJson(responseBody, LoginResponse::class.java)
// println("✅ 로그인 성공!") println("✅ 로그인 성공!")
// return loginResponse.token return loginResponse.token
// } }
// } catch (e: IOException) { } catch (e: IOException) {
// println("❌ 로그인 중 오류 발생: ${e.message}") println("❌ 로그인 중 오류 발생: ${e.message}")
// return null return null
// } }
// } }
//
// /** /**
// * 발급받은 토큰을 사용하여 북마크 저장 API를 호출합니다. * 발급받은 토큰을 사용하여 북마크 저장 API를 호출합니다.
// */ */
// private fun saveBookmarkWithToken(token: String) { private fun saveBookmarkWithToken(token: String) {
// println("\n2. 발급받은 토큰으로 북마크 저장을 시도합니다... ${imageToUpload.absolutePath}") println("\n2. 발급받은 토큰으로 북마크 저장을 시도합니다... ${imageToUpload.absolutePath}")
//
// if (!imageToUpload.exists()) { if (!imageToUpload.exists()) {
// println("❌ 파일 없음: '${imageToUpload.path}' 경로에 테스트 이미지가 존재하지 않습니다.") println("❌ 파일 없음: '${imageToUpload.path}' 경로에 테스트 이미지가 존재하지 않습니다.")
// return return
// } }
//
// // Multipart 요청 본문 생성 // Multipart 요청 본문 생성
// val requestBody = MultipartBody.Builder() val requestBody = MultipartBody.Builder()
// .setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
// .addFormDataPart( .addFormDataPart(
// "bookmarkData", "bookmarkData",
// """{"url":"https://m.cafe.daum.net/dotax/Elgq/4636033","userComment":"Kotlin 테스트 코멘트","visibility":"PUBLIC"}""" """{"url":"https://m.cafe.daum.net/dotax/Elgq/4636033","userComment":"Kotlin 테스트 코멘트","visibility":"PUBLIC"}"""
// ) )
// .addFormDataPart( .addFormDataPart(
// "imageFile", "imageFile",
// imageToUpload.name, imageToUpload.name,
// imageToUpload.asRequestBody("image/jpeg".toMediaType()) imageToUpload.asRequestBody("image/jpeg".toMediaType())
// ) )
// .build() .build()
//
// val request = Request.Builder() val request = Request.Builder()
// .url("$baseUrl/api/bookmarks/with-image") .url("$baseUrl/api/bookmarks/with-image")
// .header("Authorization", "Bearer $token") // 헤더에 JWT 토큰 추가 .header("Authorization", "Bearer $token") // 헤더에 JWT 토큰 추가
// .post(requestBody) .post(requestBody)
// .build() .build()
//
// try { try {
// client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
// println("✅ 북마크 저장 요청 완료! 응답 코드: ${response.code}") println("✅ 북마크 저장 요청 완료! 응답 코드: ${response.code}")
// println("응답 내용: ${response.body?.string()}") println("응답 내용: ${response.body?.string()}")
// } }
// } catch (e: IOException) { } catch (e: IOException) {
// println("❌ 북마크 저장 중 오류 발생: ${e.message}") println("❌ 북마크 저장 중 오류 발생: ${e.message}")
// } }
// } }
//
// /** /**
// * 전체 테스트 시나리오를 실행합니다. * 전체 테스트 시나리오를 실행합니다.
// */ */
// fun runBookmarkTest() { fun runBookmarkTest() {
// val token = loginAndGetToken() val token = loginAndGetToken()
// if (token != null) { if (token != null) {
// saveBookmarkWithToken(token) saveBookmarkWithToken(token)
// } else { } else {
// println("\n테스트 중단: 로그인에 실패하여 북마크 저장을 진행할 수 없습니다.") println("\n테스트 중단: 로그인에 실패하여 북마크 저장을 진행할 수 없습니다.")
// } }
// } }
//} }

View File

@ -37,57 +37,14 @@ class AppConfig : WebMvcConfigurer {
registry.addInterceptor(authInterceptor()) registry.addInterceptor(authInterceptor())
.addPathPatterns( .addPathPatterns(
"/home.bs", "/home.bs",
"/bums/where.bs" , "/bums/where.bs",
"/user/info", // "내 정보" 페이지도 추가하면 좋습니다.
"/tlg/repotToMe.bjx", "/tlg/repotToMe.bjx",
"/user/login.bs", "/user/signup.bs","/user/login.bjx", "/user/login.bs", "/user/signup.bs", "/user/login.bjx",
"/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.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<String,String>{
// 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())
// }
} }

View File

@ -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
// })
// }
//}

View File

@ -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.EncType11
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey
import kr.lunaticbum.back.lun.model.UserManager import kr.lunaticbum.back.lun.model.UserManager
import kr.lunaticbum.back.lun.utils.JwtUtil
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.lang.Nullable import org.springframework.lang.Nullable
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.web.authentication.RememberMeServices import org.springframework.security.web.authentication.RememberMeServices
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -18,11 +20,14 @@ import org.springframework.web.servlet.HandlerInterceptor
import org.springframework.web.servlet.ModelAndView import org.springframework.web.servlet.ModelAndView
@Component @Component
class BumsInterceptor : HandlerInterceptor { class BumsInterceptor(
) : HandlerInterceptor {
@Autowired @Autowired
lateinit var globalEvv : GlobalEnvironment lateinit var globalEvv : GlobalEnvironment
@Autowired
lateinit var jwtUtil: JwtUtil
val WRITE_PERMISSION_KEY = "PERMISSION" val WRITE_PERMISSION_KEY = "PERMISSION"
@Throws(Exception::class) @Throws(Exception::class)
@ -48,28 +53,27 @@ class BumsInterceptor : HandlerInterceptor {
handler: Any, handler: Any,
@Nullable modelAndView: ModelAndView? @Nullable modelAndView: ModelAndView?
) { ) {
println("modelAndView modelMap size >>> ${modelAndView?.modelMap?.keys?.size}")
// [수정] modelAndView가 null이 아닐 경우에만 로직을 실행하도록 변경합니다.
modelAndView?.modelMap?.put(EncTypeKey, EncType11) if (modelAndView != null && modelAndView.hasView()) {
modelAndView?.modelMap?.put(ApiKeyWordKey,"Def")
if (modelAndView != null) {
modelAndView.modelMap.put(EncTypeKey, EncType11) modelAndView.modelMap.put(EncTypeKey, EncType11)
modelAndView.modelMap.put(ApiKeyWordKey, "Def") 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이라 모델에 값 추가 불가") println("modelAndView가 null이라 모델에 값 추가 불가")
} }
super.postHandle(request, response, handler, modelAndView) 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
}
} }

View File

@ -1,71 +1,71 @@
package kr.lunaticbum.back.lun.configs //package kr.lunaticbum.back.lun.configs
//
import io.jsonwebtoken.Jwts //import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm //import io.jsonwebtoken.SignatureAlgorithm
import kr.lunaticbum.back.lun.model.User //import kr.lunaticbum.back.lun.model.User
import lombok.Getter //import lombok.Getter
import lombok.RequiredArgsConstructor //import lombok.RequiredArgsConstructor
import org.springframework.stereotype.Component //import org.springframework.stereotype.Component
import java.security.Key //import java.security.Key
import java.util.* //import java.util.*
import kotlin.collections.HashMap //import kotlin.collections.HashMap
//
//
@Component //@Component
class JwtGenerator { //class JwtGenerator {
fun generateAccessToken(ACCESS_SECRET: Key?, ACCESS_EXPIRATION: Long, user: User): String { // fun generateAccessToken(ACCESS_SECRET: Key?, ACCESS_EXPIRATION: Long, user: User): String {
val now = System.currentTimeMillis() // val now = System.currentTimeMillis()
//
return Jwts.builder() // return Jwts.builder()
.setHeader(createHeader()) // .setHeader(createHeader())
.setClaims(createClaims(user)) // .setClaims(createClaims(user))
.setSubject(user.userId) // .setSubject(user.userId)
.setExpiration(Date(now + ACCESS_EXPIRATION)) // .setExpiration(Date(now + ACCESS_EXPIRATION))
.signWith(ACCESS_SECRET, SignatureAlgorithm.HS256) // .signWith(ACCESS_SECRET, SignatureAlgorithm.HS256)
.compact() // .compact()
} // }
//
fun generateRefreshToken(REFRESH_SECRET: Key?, REFRESH_EXPIRATION: Long, user: User): String { // fun generateRefreshToken(REFRESH_SECRET: Key?, REFRESH_EXPIRATION: Long, user: User): String {
val now = System.currentTimeMillis() // val now = System.currentTimeMillis()
//
return Jwts.builder() // return Jwts.builder()
.setHeader(createHeader()) // .setHeader(createHeader())
.setClaims(createClaims(user)) // .setClaims(createClaims(user))
.setSubject(user.getIdentifier()) // .setSubject(user.getIdentifier())
.setExpiration(Date(now + REFRESH_EXPIRATION)) // .setExpiration(Date(now + REFRESH_EXPIRATION))
.signWith(REFRESH_SECRET, SignatureAlgorithm.HS256) // .signWith(REFRESH_SECRET, SignatureAlgorithm.HS256)
.compact() // .compact()
} // }
//
//
private fun createHeader(): Map<String, Any> { // private fun createHeader(): Map<String, Any> {
val header: MutableMap<String, Any> = HashMap() // val header: MutableMap<String, Any> = HashMap()
header["typ"] = "JWT" // header["typ"] = "JWT"
header["alg"] = "HS256" // header["alg"] = "HS256"
return header // return header
} // }
//
private fun createClaims(user: User): Map<String, Any?> { // private fun createClaims(user: User): Map<String, Any?> {
val claims: MutableMap<String, Any?> = HashMap() // val claims: MutableMap<String, Any?> = HashMap()
claims["Identifier"] = user.getIdentifier() // claims["Identifier"] = user.getIdentifier()
claims["Role"] = user.getRole() // claims["Role"] = user.getRole()
return claims // return claims
} // }
} //}
//
@RequiredArgsConstructor //@RequiredArgsConstructor
@Getter //@Getter
enum class TokenStatus { //enum class TokenStatus {
AUTHENTICATED, // AUTHENTICATED,
EXPIRED, // EXPIRED,
INVALID // INVALID
} //}
//
@RequiredArgsConstructor //@RequiredArgsConstructor
@Getter //@Getter
enum class JwtRule(val value: String) { //enum class JwtRule(val value: String) {
JWT_ISSUE_HEADER("Set-Cookie"), // JWT_ISSUE_HEADER("Set-Cookie"),
JWT_RESOLVE_HEADER("Cookie"), // JWT_RESOLVE_HEADER("Cookie"),
ACCESS_PREFIX("access"), // ACCESS_PREFIX("access"),
REFRESH_PREFIX("refresh"); // REFRESH_PREFIX("refresh");
} //}

View File

@ -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()
// }
}

View File

@ -38,11 +38,19 @@ import jakarta.servlet.FilterChain
import kr.lunaticbum.back.lun.utils.JwtUtil import kr.lunaticbum.back.lun.utils.JwtUtil
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.http.SessionCreationPolicy 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.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource 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 org.springframework.web.filter.OncePerRequestFilter
import java.security.SignatureException import java.security.SignatureException
import java.util.Date
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@ -51,11 +59,17 @@ class SecurityConfig(
private val jwtUtil: JwtUtil, private val jwtUtil: JwtUtil,
private val userManager: UserManager, private val userManager: UserManager,
private val bCryptPasswordEncoder: BCryptPasswordEncoder, private val bCryptPasswordEncoder: BCryptPasswordEncoder,
private val tokenRepository: MongoPersistentTokenRepository private val tokenRepository: MongoPersistentTokenRepository,
private val customAccessDeniedHandler: CustomAccessDeniedHandler
) { ) {
@Autowired @Autowired
lateinit var logService: LogService lateinit var logService: LogService
@Bean
fun securityContextRepository(): SecurityContextRepository {
return ApiAndWebSecurityContextRepository()
}
@Bean @Bean
fun webSecurityCustomizer(): WebSecurityCustomizer { fun webSecurityCustomizer(): WebSecurityCustomizer {
// 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다. // 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다.
@ -89,18 +103,22 @@ class SecurityConfig(
@Bean @Bean
@Order(1) // API 보안 설정을 먼저 적용 @Order(1) // API 보안 설정을 먼저 적용
fun apiFilterChain(http: HttpSecurity): SecurityFilterChain { fun apiFilterChain(http: HttpSecurity): SecurityFilterChain {
http http.securityContext { context ->
context.securityContextRepository(securityContextRepository())
}
.securityMatcher("/api/**") // 이 설정은 /api/ 경로에만 적용됨 .securityMatcher("/api/**") // 이 설정은 /api/ 경로에만 적용됨
.csrf { it.disable() } .csrf { it.disable() }
.cors { it.configurationSource(corsConfigurationSource()) } .cors { it.configurationSource(corsConfigurationSource()) }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음 .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음
.authorizeHttpRequests { auth -> .authorizeHttpRequests { auth ->
auth auth
.requestMatchers(HttpMethod.GET, "/api/images/**").permitAll()
.requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용 .requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용
.anyRequest().authenticated() // 나머지 API는 인증 필요 .anyRequest().authenticated() // 나머지 API는 인증 필요
} }
.exceptionHandling { handling -> .exceptionHandling { handling ->
handling.authenticationEntryPoint(jwtAuthenticationEntryPoint()) // handling.authenticationEntryPoint(jwtAuthenticationEntryPoint())
handling.accessDeniedHandler(accessDeniedHandler2)
} }
// 모든 API 요청 전에 JWT 토큰을 검증하는 필터 추가 // 모든 API 요청 전에 JWT 토큰을 검증하는 필터 추가
.addFilterBefore(JwtAuthenticationFilter(jwtUtil, userManager), UsernamePasswordAuthenticationFilter::class.java) .addFilterBefore(JwtAuthenticationFilter(jwtUtil, userManager), UsernamePasswordAuthenticationFilter::class.java)
@ -126,9 +144,12 @@ class SecurityConfig(
@Bean @Bean
@Order(2) // 웹 페이지 보안 설정 @Order(2) // 웹 페이지 보안 설정
fun webFilterChain(http: HttpSecurity): SecurityFilterChain { fun webFilterChain(http: HttpSecurity): SecurityFilterChain {
http.securityMatcher(NegatedRequestMatcher(AntPathRequestMatcher("/api/**")))
http.cors { } http.cors { }
.csrf { csrf -> .csrf { csrf ->
csrf.ignoringRequestMatchers( csrf.ignoringRequestMatchers(
"/api/**", // <-- 이 줄을 추가하세요!
"/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx", "/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx",
"/api/ranks/submit", "/api/ranks/submit",
"/bums/save/loc.api", "/bums/save/loc.api",
@ -140,7 +161,6 @@ class SecurityConfig(
.requestMatchers( .requestMatchers(
"/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**" "/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**"
).permitAll() ).permitAll()
// 2. 공개 GET API 및 페이지 = permitAll // 2. 공개 GET API 및 페이지 = permitAll
.requestMatchers(HttpMethod.GET, .requestMatchers(HttpMethod.GET,
"/api/images/**", "/api/images/**",
@ -200,13 +220,28 @@ class SecurityConfig(
}.logout { logout -> }.logout { logout ->
logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll() logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll()
}.exceptionHandling { handling -> }.exceptionHandling { handling ->
handling handling.accessDeniedHandler(customAccessDeniedHandler)
.authenticationEntryPoint(unauthorizedEntryPoint) // 인증되지 않은 사용자가 접근 시 // .authenticationEntryPoint(unauthorizedEntryPoint) // 인증되지 않은 사용자가 접근 시
.accessDeniedHandler(accessDeniedHandler) // 인증은 되었으나 권한이 없는 사용자가 접근 시 // .accessDeniedHandler(accessDeniedHandler) // 인증은 되었으나 권한이 없는 사용자가 접근 시
} }
return http.build() 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 @Bean
fun authenticationManager(http: HttpSecurity): AuthenticationManager { fun authenticationManager(http: HttpSecurity): AuthenticationManager {
val authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder::class.java) val authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder::class.java)
@ -295,4 +330,64 @@ class JwtAuthenticationFilter(
filterChain.doFilter(request, response) filterChain.doFilter(request, response)
println("JWT Token validated") println("JWT Token validated")
} }
} }
@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)
}
}
}

View File

@ -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("/**")
//// }
//}

View File

@ -6,6 +6,7 @@ import com.google.gson.Gson
import com.google.gson.JsonParser import com.google.gson.JsonParser
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.configs.GlobalEnvironment import kr.lunaticbum.back.lun.configs.GlobalEnvironment
@ -873,20 +874,41 @@ class BookmarkController(private val bookmarkService: WebBookmarkService,
private val imageMetaService: ImageMetaService, private val imageMetaService: ImageMetaService,
private val commentService: CommentService, // [신규 추가] CommentService 주입 private val commentService: CommentService, // [신규 추가] CommentService 주입
private val objectMapper: ObjectMapper // [신규 추가] JSON 처리를 위해 주입 private val objectMapper: ObjectMapper // [신규 추가] JSON 처리를 위해 주입
) { ) {
@GetMapping @GetMapping
suspend fun bookmarkListPage( suspend fun bookmarkListPage(
@RequestParam(value = "page", defaultValue = "0") page: Int, @RequestParam(value = "page", defaultValue = "0") page: Int,
@RequestParam(required = false) category: String?, // 카테고리 파라미터 받기
@RequestParam(required = false) tag: String?, // 태그 파라미터 받기
@AuthenticationPrincipal userDetails: UserDetails? @AuthenticationPrincipal userDetails: UserDetails?
): ResultMV { ): ResultMV {
val vm = ResultMV("content/bookmarks") // 북마크 전용 뷰 템플릿 val vm = ResultMV("content/bookmarks")
val pageable = PageRequest.of(page, 9) // 한 페이지에 9개씩 (3x3 그리드) 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<String>().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("저장된 페이지 목록") vm.setTitle("저장된 페이지 목록")
return vm return vm
} }
@ -949,7 +971,7 @@ class BookmarkController(private val bookmarkService: WebBookmarkService,
@AuthenticationPrincipal userDetails: UserDetails?, @AuthenticationPrincipal userDetails: UserDetails?,
pageable: Pageable pageable: Pageable
): ResponseEntity<Page<WebBookmark>> { ): ResponseEntity<Page<WebBookmark>> {
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle() val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable,null,null).awaitSingle()
return ResponseEntity.ok(bookmarksPage) return ResponseEntity.ok(bookmarksPage)
} }
@ -980,8 +1002,51 @@ class BookmarkController(private val bookmarkService: WebBookmarkService,
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) } .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<List<String>> {
return bookmarkService.findAllDistinctCategories().collectList()
}
// [신규] 모든 북마크의 고유 태그 목록을 반환하는 API
@GetMapping("/tags")
fun getBookmarkTags(): Mono<List<String>> {
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<Page<WebBookmark>> {
// 기존 서비스 메서드를 그대로 사용하여 사용자 권한에 맞는 북마크 목록을 가져옴
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable,null,null).awaitSingle()
return ResponseEntity.ok(bookmarksPage)
}
data class BookmarkDataDto( data class BookmarkDataDto(
val url: String, val url: String,
val bookmarkType : String,
val userComment: String?, val userComment: String?,
val visibility: String? val visibility: String?
) )
@ -1000,7 +1065,7 @@ class BookmarkController(private val bookmarkService: WebBookmarkService,
val uniqueFilename = "${UUID.randomUUID()}_${imageFile.originalFilename}" val uniqueFilename = "${UUID.randomUUID()}_${imageFile.originalFilename}"
val targetPath = Paths.get(uploadPath, uniqueFilename) val targetPath = Paths.get(uploadPath, uniqueFilename)
try { try {
Files.createDirectories(targetPath.parent) // Files.createDirectories(targetPath.parent)
imageFile.transferTo(targetPath.toFile()) imageFile.transferTo(targetPath.toFile())
} catch (e: Exception) { } catch (e: Exception) {
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()) 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) } .map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }
} }
}
// BlogController.kt의 BookmarkApiController 내부
@RestController @PostMapping("/with-content", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@RequestMapping("/api/bookmarks") fun saveBookmarkWithContent(
class BookmarkApiController( @RequestPart("files") files: List<MultipartFile>, // [수정] 단일 파일 -> 파일 목록
private val bookmarkService: WebBookmarkService, @RequestPart("bookmarkData") bookmarkDataJson: String,
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<Page<WebBookmark>> {
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? @AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<WebBookmark>> { ): Mono<ResponseEntity<WebBookmark>> {
logService.log("uploadPath >>> ${uploadPath}") logService.log("uploadPath >>> ${uploadPath}")
@ -1073,23 +1103,22 @@ class BookmarkApiController(
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()) 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 객체로 변환할 수 있습니다. if (savedFilePaths.isEmpty()) {
// 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()) return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build())
} }
@ -1100,18 +1129,157 @@ class BookmarkApiController(
val newBookmark = WebBookmark( val newBookmark = WebBookmark(
userId = user.username, userId = user.username,
url = bookmarkData.url, url = bookmarkData.url,
// [수정] bookmarkType을 DTO에서 받아오도록 변경 (예: IMAGE, VIDEO)
bookmarkType = bookmarkData.bookmarkType ?: BookmarkType.IMAGE.name,
contentUrls = savedFilePaths, // [수정] 저장된 파일 경로 목록을 contentUrls에 할당
userComment = bookmarkData.userComment, userComment = bookmarkData.userComment,
visibility = bookmarkData.visibility ?: "PRIVATE", visibility = bookmarkData.visibility ?: "PRIVATE",
metadataStatus = "PENDING", metadataStatus = "COMPLETED", // 파일이 직접 업로드되었으므로 메타데이터 처리는 완료됨
// 저장된 이미지의 서버 URL을 저장 thumbnailUrl = savedFilePaths.first() // 첫 번째 이미지를 대표 썸네일로 사용
userSelectedImageUrl = "/api/images/$uniqueFilename"
) )
println("newBookmark ${newBookmark}")
// 4. 북마크 정보 DB에 저장 // 4. 북마크 정보 DB에 저장
return bookmarkService.saveBookmark(newBookmark) return bookmarkService.saveBookmark(newBookmark)
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }.apply { .map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }
println("OK") }
}
/**
* [수정] ID로 단일 북마크를 가져옵니다.
* 엔드포인트가 누락되어 401/404 오류가 발생했습니다.
*/
@GetMapping("/{id}")
suspend fun getBookmarkById(
@PathVariable id: String,
@AuthenticationPrincipal userDetails: UserDetails?
): ResponseEntity<WebBookmark> {
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<String>?
)
data class TagResponse(val resultCode: Int = 0, val resultMsg: String = "OK", val tags: List<String>)
@DeleteMapping("/{id}")
suspend fun deleteBookmark(
@PathVariable id: String,
@AuthenticationPrincipal userDetails: UserDetails?
): ResponseEntity<Map<String, Any>> { // [수정] 반환 타입 변경
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 파일의 경로
}
}

View File

@ -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.ApiKeyWordKey
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11 import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey 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.model.*
import kr.lunaticbum.back.lun.utils.JwtUtil import kr.lunaticbum.back.lun.utils.JwtUtil
import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.LogService
@ -50,8 +49,8 @@ class UserController(
private val gameRankService: GameRankService, // [신규 추가] GameRankService 의존성 주입 private val gameRankService: GameRankService, // [신규 추가] GameRankService 의존성 주입
private val messageService: MessageService, private val messageService: MessageService,
private val webBookmarkService: WebBookmarkService, 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 val principal = authResult?.principal
if (principal is UserDetails) { if (principal is UserDetails) {
val token = jwtUtil.generateToken(principal)
loginResult.token = token // 2. 응답 객체에 토큰 추가
println("target.remeberMe >>> ${target.rememberMe}") println("target.remeberMe >>> ${target.rememberMe}")
loginResult.rememberMe = target.rememberMe loginResult.rememberMe = target.rememberMe
if (target.rememberMe == true) { if (target.rememberMe == true) {
@ -266,6 +269,14 @@ class UserController(
val myPosts = postManager.findPostsByWriter(username, PageRequest.of(0, 10)).collectList().block() val myPosts = postManager.findPostsByWriter(username, PageRequest.of(0, 10)).collectList().block()
vm.modelMap["myPosts"] = myPosts ?: emptyList() vm.modelMap["myPosts"] = myPosts ?: emptyList()
// 사용자가 저장한 모든 북마크 목록을 가져옵니다.
val myBookmarks = webBookmarkService.getBookmarksForUser(username) // 1. 모든 북마크를 Flux로 가져옴
.collectList() // 2. Flux 스트림을 Mono<List<WebBookmark>>으로 변환
.block() // 3. 최종적으로 List<WebBookmark>으로 변환
// 모델에 "myBookmarks" 라는 키로 저장된 북마크 리스트를 추가합니다.
vm.modelMap["myBookmarks"] = myBookmarks ?: emptyList()
// 3. 내가 쓴 댓글 목록 조회 (최신 10개) // 3. 내가 쓴 댓글 목록 조회 (최신 10개)
val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block() val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block()
vm.modelMap["myComments"] = myComments ?: emptyList() vm.modelMap["myComments"] = myComments ?: emptyList()

View File

@ -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

View File

@ -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<GameRank, String> {
//
// fun save(gameRank: GameRank): Mono<GameRank>
// // 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048)
// fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(
// gameType: GameType,
// contextId: String?
// ): Flux<GameRank>
//
// // 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수)
// fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(
// gameType: GameType,
// contextId: String?
// ): Flux<GameRank>
//
// // [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회
// fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux<GameRank>
//}
//
//
//@Service
//class GameRankService(
// private val rankRepository: GameRankRepository,
// private val userManager: UserManager ) {
// /**
// * 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다.
// */
// fun getRanks(gameType: GameType, contextId: String?): Flux<GameRank> {
// 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<GameRank> {
// 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<GameRank> { 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<GameRank> {
// return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName)
// }
//}

View File

@ -1055,15 +1055,22 @@ enum class MetadataStatus {
FAILED // 처리 실패 FAILED // 처리 실패
} }
enum class BookmarkType {
URL, // 기존 웹 페이지 링크
IMAGE, // 하나 이상의 이미지
VIDEO // 하나 이상의 비디오
}
@Document(collection = "WebBookmark") @Document(collection = "WebBookmark")
data class WebBookmark( data class WebBookmark(
@BsonId @BsonId
@BsonRepresentation(BsonType.OBJECT_ID) @BsonRepresentation(BsonType.OBJECT_ID)
var id: String? = null, var id: String? = null,
var userId: String, // 누가 저장했는지 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<String> = emptyList(),
var title: String? = null, // 페이지 제목 var title: String? = null, // 페이지 제목
var description: String? = null, // 페이지 요약 (메타 태그) var description: String? = null, // 페이지 요약 (메타 태그)
var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그) var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그)
@ -1076,8 +1083,32 @@ data class WebBookmark(
var voteCount: Long = 0, var voteCount: Long = 0,
var unlikeCount: Long = 0, var unlikeCount: Long = 0,
var userSelectedImageUrl: String? = null, 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 @Repository
interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> { interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> {
@ -1086,6 +1117,13 @@ interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> {
fun countByVisibilityIn(visibilities: List<String>): Mono<Long> fun countByVisibilityIn(visibilities: List<String>): Mono<Long>
fun findByMetadataStatus(status: String): Flux<WebBookmark> fun findByMetadataStatus(status: String): Flux<WebBookmark>
// [추가] 필터링을 위한 고유 카테고리 및 태그 목록 조회 (이 위치로 이동)
@Aggregation("{ \$unwind: '\$tags' }", "{ \$group: { _id: '\$tags' } }")
fun findDistinctTags(): Flux<Map<String, Any>>
@Aggregation("{ \$group: { _id: '\$category' } }")
fun findDistinctCategories(): Flux<Map<String, Any>>
} }
@Service @Service
@ -1093,6 +1131,25 @@ class WebBookmarkService(private val repository: WebBookmarkRepository,
private val reactiveMongoTemplate: ReactiveMongoTemplate private val reactiveMongoTemplate: ReactiveMongoTemplate
// [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다. // [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다.
) { ) {
// [이 메소드를 추가하세요]
fun findById(id: String): Mono<WebBookmark> {
return repository.findById(id)
}
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
fun findAllDistinctCategories(): Flux<String> {
return repository.findDistinctCategories()
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
.filter { it.isNotBlank() }
}
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
fun findAllDistinctTags(): Flux<String> {
return repository.findDistinctTags()
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
.filter { it.isNotBlank() }
}
fun getBookmarksForUser(userId: String): Flux<WebBookmark> { fun getBookmarksForUser(userId: String): Flux<WebBookmark> {
return repository.findByUserIdOrderBySavedAtDesc(userId) return repository.findByUserIdOrderBySavedAtDesc(userId)
} }
@ -1107,24 +1164,37 @@ class WebBookmarkService(private val repository: WebBookmarkRepository,
return repository.deleteById(id) return repository.deleteById(id)
} }
// [신규 추가] 사용자의 권한에 따라 볼 수 있는 북마크 목록을 페이지네이션으로 조회 // [수정] getVisibleBookmarks 메소드에 필터링 기능 추가
fun getVisibleBookmarks(userDetails: UserDetails?, pageable: Pageable): Mono<Page<WebBookmark>> { fun getVisibleBookmarks(
userDetails: UserDetails?,
pageable: Pageable,
category: String?, // 카테고리 파라미터 추가
tag: String? // 태그 파라미터 추가
): Mono<Page<WebBookmark>> {
val visibleScopes = when { 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) userDetails != null -> listOf(Visibility.PUBLIC.name, Visibility.MEMBERS.name)
// 비로그인 사용자일 경우 PUBLIC만 조회 가능
else -> listOf(Visibility.PUBLIC.name) 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 -> return Mono.zip(bookmarks, totalCount).map { tuple ->
PageImpl(tuple.t1, pageable, tuple.t2) PageImpl(tuple.t1, pageable, tuple.t2)
} }

View File

@ -17,6 +17,8 @@ open class PostsResult : BaseResult() {
@Getter @Getter
open class LoginResult : ResponceResult() { open class LoginResult : ResponceResult() {
var rememberMe: Boolean? = null var rememberMe: Boolean? = null
var token: String? = null // [추가] JWT 토큰을 담을 필드
} }
@Getter @Getter

View File

@ -20,7 +20,7 @@ import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import java.time.Duration import java.time.Duration
import com.fasterxml.jackson.annotation.JsonProperty
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@ -32,6 +32,7 @@ data class User (
@BsonRepresentation(BsonType.OBJECT_ID) @BsonRepresentation(BsonType.OBJECT_ID)
var userId: String? = null, var userId: String? = null,
@Id @Id
var user_id: String? = null, var user_id: String? = null,
var user_pw: String? = null, var user_pw: String? = null,

View File

@ -1,6 +1,7 @@
package kr.lunaticbum.back.lun.service 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.MetadataStatus
import kr.lunaticbum.back.lun.model.WebBookmark import kr.lunaticbum.back.lun.model.WebBookmark
import kr.lunaticbum.back.lun.model.WebBookmarkRepository import kr.lunaticbum.back.lun.model.WebBookmarkRepository
@ -39,20 +40,23 @@ class BookmarkProcessorService(
return Mono.fromCallable { return Mono.fromCallable {
// Jsoup 호출은 블로킹(blocking) 작업이므로 fromCallable로 감싸고 // Jsoup 호출은 블로킹(blocking) 작업이므로 fromCallable로 감싸고
// 별도 스레드에서 실행되도록 subscribeOn을 사용 // 별도 스레드에서 실행되도록 subscribeOn을 사용
logService.log("Fetching metadata for: ${bookmark.url}") if(bookmark.bookmarkType.equals(BookmarkType.URL.name, ignoreCase = true)){
val doc = Jsoup.connect(bookmark.url).timeout(10000).get() // 10초 타임아웃 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 title = doc.select("meta[property=og:title]").attr("content").ifEmpty { doc.title() }
val description = doc.select("meta[property=og:description]").attr("content") val description = doc.select("meta[property=og:description]").attr("content")
val imageUrl = doc.select("meta[property=og:image]").attr("content") val imageUrl = doc.select("meta[property=og:image]").attr("content")
// 북마크 객체 업데이트 // 북마크 객체 업데이트
bookmark.title = title bookmark.title = title
bookmark.description = description bookmark.description = description
bookmark.thumbnailUrl = imageUrl bookmark.thumbnailUrl = imageUrl
bookmark.metadataStatus = MetadataStatus.COMPLETED.name // 상태를 COMPLETED로 변경 bookmark.metadataStatus = MetadataStatus.COMPLETED.name // 상태를 COMPLETED로 변경
}
bookmark bookmark
} }
.subscribeOn(Schedulers.boundedElastic()) .subscribeOn(Schedulers.boundedElastic())
.flatMap { updatedBookmark -> .flatMap { updatedBookmark ->

View File

@ -103,4 +103,5 @@ spring.webflux.response-timeout=60s
api.base-url=ss api.base-url=ss
build.config.run=local build.config.run=local
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
jwt.expiration=86400000 jwt.expiration=86400000
logging.level.org.springframework.security=DEBUG

View File

@ -103,4 +103,5 @@ spring.webflux.response-timeout=60s
api.base-url=ss api.base-url=ss
build.config.run=prd build.config.run=prd
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
jwt.expiration=86400000 jwt.expiration=86400000
logging.level.org.springframework.security=DEBUG

View File

@ -104,4 +104,5 @@ api.base-url=ss
build.config.run=local build.config.run=local
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
jwt.expiration=86400000 jwt.expiration=86400000
logging.level.org.springframework.security=DEBUG

View File

@ -1777,4 +1777,135 @@ function openBookmarkInIframe(url, title) {
// 팝업과 오버레이 표시 // 팝업과 오버레이 표시
overlay.style.display = 'block'; overlay.style.display = 'block';
popup.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 ? `<span class="tag-item">${stagedBookmarkCategory} <span class="remove-tag" onclick="stagedBookmarkCategory=''; renderStagedBookmarkCategory();">X</span></span>` : '<i>선택된 카테고리 없음</i>';
}
function applyBookmarkCategory() {
document.getElementById(bookmarkPopupTargets.inputId).value = stagedBookmarkCategory;
document.getElementById(bookmarkPopupTargets.displayId).innerHTML = stagedBookmarkCategory ? `<span class="tag-item">${stagedBookmarkCategory}</span>` : '카테고리 선택';
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) => `<span class="tag-item">#${tag} <span class="remove-tag" onclick="removeStagedBookmarkTag(${i})">X</span></span>`).join(' ') || '<i>선택된 태그 없음</i>';
}
function applyBookmarkTags() {
const tagsString = stagedBookmarkTags.join(',');
document.getElementById(bookmarkPopupTargets.inputId).value = tagsString;
document.getElementById(bookmarkPopupTargets.displayId).innerHTML = stagedBookmarkTags.map(tag => `<span class="tag-item">#${tag}</span>`).join(' ') || '태그 선택';
closePopup();
} }

View File

@ -2,9 +2,35 @@
<html <html
xmlns:th="http://www.thymeleaf.org" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}"> layout:decorate="~{layout/default_layout}">
<head> <head>
<title>Bookmarks</title> <title>Bookmarks</title>
<style>
.scrollable-content {
max-height: 500px; /* 콘텐츠 영역의 최대 높이를 지정 */
max-width: 500px;
overflow-y: auto; /* 세로 내용이 넘칠 경우 스크롤바 자동 생성 */
-webkit-overflow-scrolling: touch; /* 모바일에서 부드러운 스크롤 효과 */
}
</style>
<link rel="stylesheet" href="https://unpkg.com/swiper/swiper-bundle.min.css" />
<script src="https://unpkg.com/swiper/swiper-bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const swiper = new Swiper('.bookmark-swiper', {
loop: false,
pagination: {
el: '.swiper-pagination',
clickable: true,
},
navigation: {
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev',
},
});
});
</script>
</head> </head>
<th:block layout:fragment="content"> <th:block layout:fragment="content">
<section class="wrapper style2"> <section class="wrapper style2">
@ -12,71 +38,103 @@
<header class="major"> <header class="major">
<h2>Bookmarks</h2> <h2>Bookmarks</h2>
<p>다른 사용자들이 저장한 유용한 페이지들을 둘러보세요.</p> <p>다른 사용자들이 저장한 유용한 페이지들을 둘러보세요.</p>
<div class="filter-controls" style="margin-bottom: 2em; text-align: center;">
<div style="margin-bottom: 1em;">
<strong>카테고리:</strong>
<a th:href="@{/bookmarks}" th:classappend="${currentCategory == null && currentTag == null} ? 'button small' : 'button alt small'">전체</a>
<a th:each="cat : ${allCategories}"
th:href="@{/bookmarks(category=${cat})}"
th:text="${cat}"
th:classappend="${currentCategory == cat} ? 'button small' : 'button alt small'"></a>
</div>
<div>
<strong>태그:</strong>
<a th:each="tg : ${allTags}"
th:href="@{/bookmarks(tag=${tg})}"
th:text="'#' + ${tg}"
th:classappend="${currentTag == tg} ? 'button small' : 'button alt small'"></a>
</div>
</div>
</header> </header>
</div> </div>
</section> </section>
<section class="wrapper style1"> <section class="wrapper style1">
<div class="container"> <div class="container">
<div class="row"> <div class="swiper bookmark-swiper">
<div class="col-12" th:if="${bookmarksPage.empty}"> <div class="swiper-wrapper">
<p style="text-align: center;">아직 저장된 페이지가 없습니다.</p> <div class="swiper-slide" th:each="bookmark : ${bookmarksPage.content}">
</div> <section class="box feature" style="margin: 0; height: 100%; display: flex; flex-direction: column;">
<div class="col-4 col-6-medium col-12-small" th:each="bookmark : ${bookmarksPage.content}">
<section class="box feature">
<a href="javascript:void(0);"
th:data-url="${bookmark.url}"
th:data-title="${bookmark.title}"
onclick="showBookmarkOptions(this)" class="image featured">
<img th:src="${bookmark.thumbnailUrl ?: '/images/pic01.jpg'}" alt="Thumbnail" />
</a>
<div class="inner">
<header>
<h3 th:text="${bookmark.title}">북마크 제목</h3>
<p th:if="${bookmark.userComment}" th:text="${bookmark.userComment}" style="font-style: italic; color: #007bff;"></p>
</header>
<p th:text="${#strings.abbreviate(bookmark.description, 100)}"></p>
<div class="bookmark-controls" style="margin-top: 1em; padding-top: 1em; border-top: 1px solid #eee;"> <div th:switch="${bookmark.bookmarkType}">
<div class="vote-controls" th:data-bookmark-id="${bookmark.id}" style="text-align: center; margin-bottom: 1em;"> <div th:case="'IMAGE'" class="image-flick-container scrollable-content">
<button class="button small alt" th:onclick="handleBookmarkVote(this, 'like')"> <img th:each="imageUrl : ${bookmark.contentUrls}" th:src="${apiBaseUrl + imageUrl}" alt="Bookmark Image" />
👍 (<span class="like-count" th:text="${bookmark.voteCount}">0</span>)
</button>
<button class="button small alt" th:onclick="handleBookmarkVote(this, 'unlike')" style="margin-left: 0.5em;">
👎 (<span class="unlike-count" th:text="${bookmark.unlikeCount}">0</span>)
</button>
</div> </div>
<a href="javascript:void(0);" th:onclick="toggleCommentSection('[[${bookmark.id}]]')" class="button small fit">댓글 보기</a> <div th:case="'VIDEO'" class="video-container" th:if="${!#lists.isEmpty(bookmark.contentUrls)}">
<div th:id="'comment-section-' + ${bookmark.id}" class="comment-section" style="display: none; margin-top: 1em;"> <video controls style="width: 100%;">
<div th:id="'comments-list-' + ${bookmark.id}" class="comments-list"></div> <source th:src="${apiBaseUrl + bookmark.contentUrls[0]}" type="video/mp4">
<textarea th:id="'comment-input-' + ${bookmark.id}" placeholder="댓글을 입력하세요..." style="margin-top: 1em;"></textarea> </video>
<button class="button small" th:onclick="submitBookmarkComment('[[${bookmark.id}]]')">등록</button>
</div> </div>
<a th:case="'URL'"
href="javascript:void(0);"
th:data-url="${bookmark.url}"
th:data-title="${bookmark.title}"
onclick="showBookmarkOptions(this)" class="image featured scrollable-content">
<img th:each="imageUrl : ${bookmark.contentUrls}" th:src="${apiBaseUrl + imageUrl}" alt="Bookmark Image" />
</a>
</div> </div>
<footer style="font-size: 0.8em; color: #888; text-align: right; margin-top: 1em;"> <div class="inner" style="flex-grow: 1; display: flex; flex-direction: column; justify-content: space-between;">
by <span th:text="${bookmark.userId}"></span> <div>
</footer> <header>
</div> <h3 th:text="${bookmark.title}">북마크 제목</h3>
</section> <p th:if="${bookmark.userComment}" th:text="${bookmark.userComment}"></p>
</header>
<p th:text="${#strings.abbreviate(bookmark.description, 100)}"></p>
</div>
<div>
<div class="vote-controls" style="margin-top: 1em; text-align: center;" th:data-bookmark-id="${bookmark.id}">
<button class="button small" onclick="handleBookmarkVote(this, 'like')">
👍 Like (<span class="like-count" th:text="${bookmark.voteCount}">0</span>)
</button>
<button class="button small" onclick="handleBookmarkVote(this, 'unlike')">
👎 Unlike (<span class="unlike-count" th:text="${bookmark.unlikeCount}">0</span>)
</button>
<button class="button alt small" th:onclick="toggleCommentSection([[${bookmark.id}]])">
💬 Comments
</button>
</div>
<section class="comment-section" th:id="|comment-section-${bookmark.id}|" style="display: none; margin-top: 1em; text-align: left;">
<th:block sec:authorize="isAuthenticated()">
<div class="comment-form-container">
<textarea th:id="|comment-input-${bookmark.id}|" placeholder="댓글을 입력하세요..." style="width: 100%;"></textarea>
<button th:onclick="submitBookmarkComment([[${bookmark.id}]])" class="button small" style="margin-top: 0.5em;">등록</button>
</div>
</th:block>
<div sec:authorize="isAnonymous()" style="padding: 1em; text-align: center; border: 1px dashed #ccc; margin-bottom: 1em;">
<p style="margin:0;">댓글을 작성하려면 <a th:href="@{/home.bs(action='login')}">로그인</a>이 필요합니다.</p>
</div>
<div th:id="|comments-list-${bookmark.id}|" style="margin-top: 1em;">
</div>
</section>
</div>
</div>
</section>
</div>
</div> </div>
<div class="swiper-pagination"></div>
<div class="swiper-button-prev"></div>
<div class="swiper-button-next"></div>
</div> </div>
<nav th:if="${bookmarksPage.totalPages > 1}" style="text-align: center; margin-top: 2.5em;"> <div th:if="${bookmarksPage.empty}">
<ul class="pagination"> <p style="text-align: center;">아직 저장된 페이지가 없습니다.</p>
<li th:classappend="${bookmarksPage.first} ? 'disabled'"> </div>
<a th:href="@{/bookmarks(page=${bookmarksPage.number - 1})}" class="button alt small">Prev</a>
</li>
<li th:each="pageNum : ${#numbers.sequence(0, bookmarksPage.totalPages - 1)}">
<a th:href="@{/bookmarks(page=${pageNum})}"
th:text="${pageNum + 1}"
th:class="${pageNum == bookmarksPage.number} ? 'button small' : 'button alt small'"></a>
</li>
<li th:classappend="${bookmarksPage.last} ? 'disabled'">
<a th:href="@{/bookmarks(page=${bookmarksPage.number + 1})}" class="button alt small">Next</a>
</li>
</ul>
</nav>
</div> </div>
</section> </section>
</th:block> </th:block>

View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="content">
<section class="wrapper style1">
<div class="container" style="text-align: center; padding: 4em 0;">
<header class="major">
<h2 th:text="|오류가 발생했습니다 (${statusCode})|">오류가 발생했습니다</h2>
<p th:text="${errorMessage}" style="font-size: 1.5em; color: #e85a4f;">오류 메시지</p>
</header>
<div class="box" style="max-width: 600px; margin: 2em auto; text-align: left;">
<p th:text="${errorDescription}">
오류에 대한 상세 설명입니다. 이 페이지는 접근이 금지되었거나, 요청 처리 중 문제가 발생했을 수 있습니다.
</p>
</div>
<a th:href="@{/}" class="button primary">홈으로 돌아가기</a>
</div>
</section>
</th:block>
</html>

View File

@ -155,6 +155,17 @@
<label for="visibility-public" class="custom-label">전체 공개</label> <label for="visibility-public" class="custom-label">전체 공개</label>
</div> </div>
</div> </div>
<div class="form-control-wrapper" onclick="openBookmarkCategoryPopup('new-bookmark-category-display', 'new-bookmark-category')">
<strong>카테고리</strong>
<div id="new-bookmark-category-display" class="tag-display-box">카테고리 선택</div>
</div>
<input type="hidden" id="new-bookmark-category">
<div class="form-control-wrapper" onclick="openBookmarkTagPopup('new-bookmark-tags-display', 'new-bookmark-tags')">
<strong>태그</strong>
<div id="new-bookmark-tags-display" class="tag-display-box">태그 선택</div>
</div>
<input type="hidden" id="new-bookmark-tags">
<button id="save-bookmark-btn" class="button primary" style="margin-top: 1em;">저장하기</button> <button id="save-bookmark-btn" class="button primary" style="margin-top: 1em;">저장하기</button>
</div> </div>
</div> </div>
@ -162,15 +173,26 @@
<div class="box" style="margin-top: 2em;"> <div class="box" style="margin-top: 2em;">
<h4>저장된 목록</h4> <h4>저장된 목록</h4>
<div id="bookmarks-list" class="row"> <div id="bookmarks-list" class="row">
<div class="col-4 col-12-medium"> <div class="col-12" th:if="${#lists.isEmpty(myBookmarks)}">
<p style="text-align: center; padding: 2em 0;">저장한 페이지가 없습니다.</p>
</div>
<div class="col-4 col-12-medium" th:each="bookmark : ${myBookmarks}" th:id="|bookmark-row-${bookmark.id}|">
<section class="box feature"> <section class="box feature">
<a href="#" class="image featured"><img src="/images/pic01.jpg" alt="" /></a> <a th:href="${bookmark.url}" target="_blank" class="image featured">
<img th:src="${apiBaseUrl + bookmark.displayImageUrl}" alt="Bookmark Thumbnail" />
</a>
<div class="inner"> <div class="inner">
<header> <header>
<h2>카드 제목</h2> <h2 th:text="${bookmark.title ?: '제목 없음'}">카드 제목</h2>
<p>사용자 코멘트가 여기에 들어갑니다.</p> <p th:if="${!#strings.isEmpty(bookmark.userComment)}" th:text="${bookmark.userComment}">사용자 코멘트</p>
</header> </header>
<p style="font-size: 0.8em; color: #888;">원본 페이지 설명...</p> <p style="font-size: 0.8em; color: #888;" th:text="${#strings.abbreviate(bookmark.description, 100)}">원본 페이지 설명...</p>
<div class="actions" style="margin-top: 1em; text-align: right;">
<button class="button small" th:onclick="openEditBookmarkModal([[${bookmark.id}]])">수정</button>
<button class="button small alt" th:onclick="deleteBookmark([[${bookmark.id}]])">삭제</button>
</div>
</div> </div>
</section> </section>
</div> </div>
@ -497,6 +519,8 @@
// [수정] 선택된 공개 범위(visibility) 값을 읽어오는 코드 추가 // [수정] 선택된 공개 범위(visibility) 값을 읽어오는 코드 추가
const visibility = document.querySelector('input[name="visibility"]:checked').value; 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 = { const bookmarkData = {
url: urlInput.value.trim(), url: urlInput.value.trim(),
@ -506,7 +530,9 @@
thumbnailUrl: ogData.thumbnailUrl, thumbnailUrl: ogData.thumbnailUrl,
userComment: comment, userComment: comment,
// [수정] bookmarkData 객체에 visibility 프로퍼티 추가 // [수정] bookmarkData 객체에 visibility 프로퍼티 추가
visibility: visibility visibility: visibility,
category: category,
tags: tags
}; };
if (!bookmarkData.url) { 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 ? `<span class="tag-item">${bookmark.category}</span>` : '카테고리 선택';
document.getElementById('edit-bookmark-category').value = bookmark.category || '';
document.getElementById('edit-bookmark-tags-display').innerHTML = (bookmark.tags || []).map(t => `<span class="tag-item">#${t}</span>`).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');
}
}
}
</script> </script>
</th:block> </th:block>
</html> </html>

View File

@ -55,7 +55,9 @@
unlikeCount: [[${srcPost?.unlikeCount ?: 0}]], unlikeCount: [[${srcPost?.unlikeCount ?: 0}]],
// --- Page-specific (not model data) --- // --- Page-specific (not model data) ---
enc: /*[[${enc ?: ''}]]*/, enc: /*[[${enc ?: ''}]]*/,
keyword: /*[[${keyword ?: ''}]]*/ keyword: /*[[${keyword ?: ''}]]*/,
// --- [핵심 추가] ---
token: /*[[${jwtToken}]]*/
}; };
</script> </script>
</th:block> </th:block>

View File

@ -73,6 +73,78 @@
</div> </div>
</div> </div>
</div> </div>
<div id="bookmark-edit-popup" class="pop_layer">
<div class="pop_container">
<div class="pop_conts">
<h2>북마크 수정</h2>
<input type="hidden" id="edit-bookmark-id">
<label for="edit-bookmark-title">제목</label>
<input type="text" id="edit-bookmark-title" placeholder="페이지 제목">
<label for="edit-bookmark-comment">내 코멘트</label>
<textarea id="edit-bookmark-comment" placeholder="나의 생각 (선택)" rows="3"></textarea>
<label for="edit-bookmark-visibility">공개 범위</label>
<select id="edit-bookmark-visibility" style="width: 100%; padding: 0.5em; border-radius: 4px; border: 1px solid #ddd;">
<option value="PRIVATE">비공개</option>
<option value="MEMBERS">회원 공개</option>
<option value="PUBLIC">전체 공개</option>
</select>
<div class="form-control-wrapper" onclick="openBookmarkCategoryPopup('edit-bookmark-category-display', 'edit-bookmark-category')">
<strong>카테고리</strong>
<div id="edit-bookmark-category-display" class="tag-display-box">카테고리 선택</div>
</div>
<input type="hidden" id="edit-bookmark-category">
<div class="form-control-wrapper" onclick="openBookmarkTagPopup('edit-bookmark-tags-display', 'edit-bookmark-tags')">
<strong>태그</strong>
<div id="edit-bookmark-tags-display" class="tag-display-box">태그 선택</div>
</div>
<input type="hidden" id="edit-bookmark-tags">
<div style="margin-top: 1.5em; text-align: right;">
<button type="button" class="button primary" onclick="submitBookmarkUpdate()">변경사항 저장</button>
<a href="#" class="button alt btn_layerClose">취소</a>
</div>
</div>
</div>
</div>
<div id="bookmark-category-popup" class="pop_layer">
<div class="pop_container">
<div class="pop_conts">
<h2>카테고리 선택</h2>
<div id="selected-bookmark-category-area" class="selected-items-area"></div>
<hr>
<div id="bookmark-category-list" class="tag-list"></div>
<input type="text" id="new-bookmark-category-input" placeholder="새 카테고리 입력 후 Enter">
<div style="margin-top: 1.5em;">
<button type="button" class="button primary" onclick="applyBookmarkCategory()">적용</button>
<a href="#" class="button alt btn_layerClose">취소</a>
</div>
</div>
</div>
</div>
<div id="bookmark-tag-popup" class="pop_layer">
<div class="pop_container">
<div class="pop_conts">
<h2>태그 선택</h2>
<div id="selected-bookmark-tags-area" class="selected-items-area"></div>
<hr>
<div id="bookmark-tag-list" class="tag-list"></div>
<input type="text" id="new-bookmark-tag-input" placeholder="새 태그 입력 후 Enter">
<div style="margin-top: 1.5em;">
<button type="button" class="button primary" onclick="applyBookmarkTags()">적용</button>
<a href="#" class="button alt btn_layerClose">취소</a>
</div>
</div>
</div>
</div>
<div id="iframe-viewer-popup" class="pop_layer" style="width: 90%; height: 90%; max-width: 1400px;"> <div id="iframe-viewer-popup" class="pop_layer" style="width: 90%; height: 90%; max-width: 1400px;">
<div class="pop_container" style="height: 100%; display: flex; flex-direction: column;"> <div class="pop_container" style="height: 100%; display: flex; flex-direction: column;">
<div class="pop_header" style="display: flex; justify-content: space-between; align-items: center; padding: 10px 20px; border-bottom: 1px solid #eee; background: #f8f8f8;"> <div class="pop_header" style="display: flex; justify-content: space-between; align-items: center; padding: 10px 20px; border-bottom: 1px solid #eee; background: #f8f8f8;">