....
This commit is contained in:
parent
5e0db4ff03
commit
4b652c4df5
@ -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테스트 중단: 로그인에 실패하여 북마크 저장을 진행할 수 없습니다.")
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
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테스트 중단: 로그인에 실패하여 북마크 저장을 진행할 수 없습니다.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<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())
|
||||
// }
|
||||
|
||||
}
|
||||
@ -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
|
||||
// })
|
||||
// }
|
||||
//}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<String, Any> {
|
||||
val header: MutableMap<String, Any> = HashMap()
|
||||
header["typ"] = "JWT"
|
||||
header["alg"] = "HS256"
|
||||
return header
|
||||
}
|
||||
|
||||
private fun createClaims(user: User): Map<String, Any?> {
|
||||
val claims: MutableMap<String, Any?> = 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");
|
||||
}
|
||||
//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<String, Any> {
|
||||
// val header: MutableMap<String, Any> = HashMap()
|
||||
// header["typ"] = "JWT"
|
||||
// header["alg"] = "HS256"
|
||||
// return header
|
||||
// }
|
||||
//
|
||||
// private fun createClaims(user: User): Map<String, Any?> {
|
||||
// val claims: MutableMap<String, Any?> = 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");
|
||||
//}
|
||||
@ -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()
|
||||
// }
|
||||
}
|
||||
@ -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)
|
||||
@ -296,3 +331,63 @@ class JwtAuthenticationFilter(
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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("/**")
|
||||
//// }
|
||||
//}
|
||||
@ -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<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("저장된 페이지 목록")
|
||||
return vm
|
||||
}
|
||||
@ -949,7 +971,7 @@ class BookmarkController(private val bookmarkService: WebBookmarkService,
|
||||
@AuthenticationPrincipal userDetails: UserDetails?,
|
||||
pageable: Pageable
|
||||
): ResponseEntity<Page<WebBookmark>> {
|
||||
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<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(
|
||||
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<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 문자열로 받음
|
||||
// BlogController.kt의 BookmarkApiController 내부
|
||||
@PostMapping("/with-content", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||
fun saveBookmarkWithContent(
|
||||
@RequestPart("files") files: List<MultipartFile>, // [수정] 단일 파일 -> 파일 목록
|
||||
@RequestPart("bookmarkData") bookmarkDataJson: String,
|
||||
@AuthenticationPrincipal user: UserDetails?
|
||||
): Mono<ResponseEntity<WebBookmark>> {
|
||||
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<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 파일의 경로
|
||||
}
|
||||
}
|
||||
@ -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<List<WebBookmark>>으로 변환
|
||||
.block() // 3. 최종적으로 List<WebBookmark>으로 변환
|
||||
|
||||
// 모델에 "myBookmarks" 라는 키로 저장된 북마크 리스트를 추가합니다.
|
||||
vm.modelMap["myBookmarks"] = myBookmarks ?: emptyList()
|
||||
|
||||
// 3. 내가 쓴 댓글 목록 조회 (최신 10개)
|
||||
val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block()
|
||||
vm.modelMap["myComments"] = myComments ?: emptyList()
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
// }
|
||||
//}
|
||||
@ -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<String> = 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<WebBookmark, String> {
|
||||
@ -1086,6 +1117,13 @@ interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> {
|
||||
fun countByVisibilityIn(visibilities: List<String>): Mono<Long>
|
||||
|
||||
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
|
||||
@ -1093,6 +1131,25 @@ class WebBookmarkService(private val repository: WebBookmarkRepository,
|
||||
private val 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> {
|
||||
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<Page<WebBookmark>> {
|
||||
// [수정] getVisibleBookmarks 메소드에 필터링 기능 추가
|
||||
fun getVisibleBookmarks(
|
||||
userDetails: UserDetails?,
|
||||
pageable: Pageable,
|
||||
category: String?, // 카테고리 파라미터 추가
|
||||
tag: String? // 태그 파라미터 추가
|
||||
): Mono<Page<WebBookmark>> {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ open class PostsResult : BaseResult() {
|
||||
@Getter
|
||||
open class LoginResult : ResponceResult() {
|
||||
var rememberMe: Boolean? = null
|
||||
|
||||
var token: String? = null // [추가] JWT 토큰을 담을 필드
|
||||
}
|
||||
|
||||
@Getter
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -104,3 +104,4 @@ 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
|
||||
logging.level.org.springframework.security=DEBUG
|
||||
@ -104,3 +104,4 @@ 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
|
||||
logging.level.org.springframework.security=DEBUG
|
||||
@ -105,3 +105,4 @@ 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
|
||||
logging.level.org.springframework.security=DEBUG
|
||||
@ -1778,3 +1778,134 @@ 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 ? `<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();
|
||||
}
|
||||
@ -2,9 +2,35 @@
|
||||
<html
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
layout:decorate="~{layout/default_layout}">
|
||||
<head>
|
||||
<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>
|
||||
<th:block layout:fragment="content">
|
||||
<section class="wrapper style2">
|
||||
@ -12,71 +38,103 @@
|
||||
<header class="major">
|
||||
<h2>Bookmarks</h2>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wrapper style1">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12" th:if="${bookmarksPage.empty}">
|
||||
<p style="text-align: center;">아직 저장된 페이지가 없습니다.</p>
|
||||
</div>
|
||||
<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="swiper bookmark-swiper">
|
||||
<div class="swiper-wrapper">
|
||||
<div class="swiper-slide" th:each="bookmark : ${bookmarksPage.content}">
|
||||
<section class="box feature" style="margin: 0; height: 100%; display: flex; flex-direction: column;">
|
||||
|
||||
<div class="bookmark-controls" style="margin-top: 1em; padding-top: 1em; border-top: 1px solid #eee;">
|
||||
<div class="vote-controls" th:data-bookmark-id="${bookmark.id}" style="text-align: center; margin-bottom: 1em;">
|
||||
<button class="button small alt" th:onclick="handleBookmarkVote(this, 'like')">
|
||||
👍 (<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 th:switch="${bookmark.bookmarkType}">
|
||||
<div th:case="'IMAGE'" class="image-flick-container scrollable-content">
|
||||
<img th:each="imageUrl : ${bookmark.contentUrls}" th:src="${apiBaseUrl + imageUrl}" alt="Bookmark Image" />
|
||||
</div>
|
||||
<a href="javascript:void(0);" th:onclick="toggleCommentSection('[[${bookmark.id}]]')" class="button small fit">댓글 보기</a>
|
||||
<div th:id="'comment-section-' + ${bookmark.id}" class="comment-section" style="display: none; margin-top: 1em;">
|
||||
<div th:id="'comments-list-' + ${bookmark.id}" class="comments-list"></div>
|
||||
<textarea th:id="'comment-input-' + ${bookmark.id}" placeholder="댓글을 입력하세요..." style="margin-top: 1em;"></textarea>
|
||||
<button class="button small" th:onclick="submitBookmarkComment('[[${bookmark.id}]]')">등록</button>
|
||||
<div th:case="'VIDEO'" class="video-container" th:if="${!#lists.isEmpty(bookmark.contentUrls)}">
|
||||
<video controls style="width: 100%;">
|
||||
<source th:src="${apiBaseUrl + bookmark.contentUrls[0]}" type="video/mp4">
|
||||
</video>
|
||||
</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>
|
||||
|
||||
<footer style="font-size: 0.8em; color: #888; text-align: right; margin-top: 1em;">
|
||||
by <span th:text="${bookmark.userId}"></span>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
<div class="inner" style="flex-grow: 1; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<header>
|
||||
<h3 th:text="${bookmark.title}">북마크 제목</h3>
|
||||
<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 class="swiper-pagination"></div>
|
||||
<div class="swiper-button-prev"></div>
|
||||
<div class="swiper-button-next"></div>
|
||||
</div>
|
||||
|
||||
<nav th:if="${bookmarksPage.totalPages > 1}" style="text-align: center; margin-top: 2.5em;">
|
||||
<ul class="pagination">
|
||||
<li th:classappend="${bookmarksPage.first} ? 'disabled'">
|
||||
<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 th:if="${bookmarksPage.empty}">
|
||||
<p style="text-align: center;">아직 저장된 페이지가 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</th:block>
|
||||
|
||||
25
src/main/resources/templates/content/error_page.html
Normal file
25
src/main/resources/templates/content/error_page.html
Normal 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>
|
||||
@ -155,6 +155,17 @@
|
||||
<label for="visibility-public" class="custom-label">전체 공개</label>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@ -162,15 +173,26 @@
|
||||
<div class="box" style="margin-top: 2em;">
|
||||
<h4>저장된 목록</h4>
|
||||
<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">
|
||||
<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">
|
||||
<header>
|
||||
<h2>카드 제목</h2>
|
||||
<p>사용자 코멘트가 여기에 들어갑니다.</p>
|
||||
<h2 th:text="${bookmark.title ?: '제목 없음'}">카드 제목</h2>
|
||||
<p th:if="${!#strings.isEmpty(bookmark.userComment)}" th:text="${bookmark.userComment}">사용자 코멘트</p>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
@ -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 ? `<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>
|
||||
</th:block>
|
||||
</html>
|
||||
@ -55,7 +55,9 @@
|
||||
unlikeCount: [[${srcPost?.unlikeCount ?: 0}]],
|
||||
// --- Page-specific (not model data) ---
|
||||
enc: /*[[${enc ?: ''}]]*/,
|
||||
keyword: /*[[${keyword ?: ''}]]*/
|
||||
keyword: /*[[${keyword ?: ''}]]*/,
|
||||
// --- [핵심 추가] ---
|
||||
token: /*[[${jwtToken}]]*/
|
||||
};
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
@ -73,6 +73,78 @@
|
||||
</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 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;">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user