This commit is contained in:
lunaticbum 2025-09-18 17:55:32 +09:00
parent 17aea8b43b
commit 5e0db4ff03
38 changed files with 3011 additions and 1228 deletions

View File

@ -109,6 +109,9 @@ dependencies {
testImplementation("io.projectreactor:reactor-test") testImplementation("io.projectreactor:reactor-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
// JSON 처리를 위한 Gson 라이브러리
implementation("com.google.code.gson:gson:2.10.1")
} }
@ -223,26 +226,101 @@ tasks.named("bootJar") { // [수정 후] 'build' 태스크를 더 안전하게
// 기본 bootJar 태스크의 설정을 가져오기 위한 참조 // 기본 bootJar 태스크의 설정을 가져오기 위한 참조
val bootJar by tasks.getting(BootJar::class) val bootJar by tasks.getting(BootJar::class)
//
//// 'prod' 프로필이 내장된 JAR를 빌드하는 최종 태스크 정의
//tasks.register<BootJar>("bootJarProd") {
// group = "build"
// description = "Builds a production JAR that defaults to the 'prod' profile."
// archiveClassifier.set("prod")
//
// // --- 필수 설정 복사 ---
// // 1. Main 클래스 설정 복사
// mainClass.set(bootJar.mainClass)
// // 2. Classpath 설정 복사
// classpath = bootJar.classpath
// // 3. Target Java Version 설정 복사 (이번 오류 해결)
// targetJavaVersion.set(bootJar.targetJavaVersion)
//
// manifest {
// attributes["Spring-Profiles-Active"] = "prod"
// }
//}
// 'prod' 프로필이 내장된 JAR를 빌드하는 최종 태스크 정의 // "local" 프로파일용 JAR를 빌드하는 작업
tasks.register<BootJar>("bootJarProd") { tasks.register<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJarLocal") {
group = "build" group = "build"
description = "Builds a production JAR that defaults to the 'prod' profile." description = "로컬 환경용 JAR 파일을 빌드합니다 ('local' 프로파일 적용)."
archiveClassifier.set("prod") archiveClassifier.set("local") // 파일 이름에 local 접미사 추가 (e.g., app-local.jar)
// --- 필수 설정 복사 --- // 메인 클래스와 클래스패스는 기본 bootJar 설정을 따라갑니다.
// 1. Main 클래스 설정 복사 mainClass.set(tasks.bootJar.get().mainClass)
mainClass.set(bootJar.mainClass) classpath = tasks.bootJar.get().classpath
// 2. Classpath 설정 복사
classpath = bootJar.classpath
// 3. Target Java Version 설정 복사 (이번 오류 해결)
targetJavaVersion.set(bootJar.targetJavaVersion) targetJavaVersion.set(bootJar.targetJavaVersion)
// 'resources' 폴더의 모든 파일을 복사하되...
manifest { from("src/main/resources") {
attributes["Spring-Profiles-Active"] = "prod" include("**/*")
// prod 설정 파일은 제외합니다.
exclude("application-prod.properties")
// local 설정 파일의 이름을 application.properties로 변경합니다.
rename("application-local.properties", "application.properties")
} }
} }
// "prod" 프로파일용 JAR를 빌드하는 작업
tasks.register<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJarProd") {
group = "build"
description = "운영 환경용 JAR 파일을 빌드합니다 ('prod' 프로파일 적용)."
archiveClassifier.set("prod") // 파일 이름에 prod 접미사 추가 (e.g., app-prod.jar)
// 메인 클래스와 클래스패스는 기본 bootJar 설정을 따라갑니다.
mainClass.set(tasks.bootJar.get().mainClass)
classpath = tasks.bootJar.get().classpath
targetJavaVersion.set(bootJar.targetJavaVersion)
// 'resources' 폴더의 모든 파일을 복사하되...
from("src/main/resources") {
include("**/*")
// local 설정 파일은 제외합니다.
exclude("application-local.properties")
// prod 설정 파일의 이름을 application.properties로 변경합니다.
rename("application-prod.properties", "application.properties")
}
}
// 🚀 1. 명령어를 실행할 새로운 Exec 태스크 정의
tasks.register<Exec>("runCommandAfterProdJar") {
group = "build"
description = "prod JAR 빌드 후 실행할 명령어를 정의합니다."
// 이 태스크는 bootJarProd가 성공해야만 의미가 있으므로, 의존성을 명시해주는 것이 좋습니다.
dependsOn(tasks.named("bootJarProd"))
// 실행할 OS 명령어와 인자를 설정합니다.
// 예시 1: Docker 이미지 빌드
commandLine("docker", "buildx","buildx","--platform","linux/amd64", "-t", "lunaticbum/testjar:0.025", ".")
// 예시 2: 빌드된 JAR 파일을 특정 서버로 복사
// commandLine("scp", "build/libs/your-app-name-prod.jar", "user@server:/path/to/deploy")
// 예시 3: 간단한 셸 스크립트 실행
// commandLine("./deploy.sh")
// 필요하다면 작업 디렉토리를 설정할 수 있습니다.
// workingDir = rootDir
// doLast {
// println("prod JAR 빌드가 완료되었습니다. 추가 명령어를 실행합니다.")
// exec {
// commandLine("docker", "push", "lunaticbum/testjar:0.025")
// // commandLine("echo", "Hello from doLast!")
// }
// }
}
// 🚀 2. bootJarProd 태스크가 끝나면 위에서 정의한 태스크를 실행하도록 연결
//tasks.named("bootJarProd") {
// finalizedBy(tasks.named("runCommandAfterProdJar"))
//}
// //
//// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결 //// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결
//tasks.build { //tasks.build {

View File

@ -1,124 +0,0 @@
//package kr.lunaticbum.back.lun.configs
//
//import io.netty.channel.ChannelOption
//import io.netty.handler.timeout.ReadTimeoutHandler
//import io.netty.handler.timeout.WriteTimeoutHandler
//import jakarta.servlet.ServletContext
//import jakarta.servlet.ServletException
//import kr.lunaticbum.back.lun.utils.LogService
//import lombok.RequiredArgsConstructor
//import org.springframework.beans.factory.annotation.Autowired
//import org.springframework.beans.factory.annotation.Qualifier
//import org.springframework.context.annotation.Bean
//import org.springframework.http.client.reactive.ReactorClientHttpConnector
//import org.springframework.web.WebApplicationInitializer
//import org.springframework.web.context.ContextLoaderListener
//import org.springframework.web.context.support.AnnotationConfigWebApplicationContext
//import org.springframework.web.reactive.function.client.ClientRequest
//import org.springframework.web.reactive.function.client.ClientResponse
//import org.springframework.web.reactive.function.client.ExchangeFilterFunction
//import org.springframework.web.reactive.function.client.WebClient
//import org.springframework.web.servlet.DispatcherServlet
//import org.springframework.web.servlet.HandlerInterceptor
//import org.springframework.web.servlet.config.annotation.InterceptorRegistry
//import org.springframework.web.util.DefaultUriBuilderFactory
//import reactor.core.publisher.Mono
//import reactor.netty.Connection
//import reactor.netty.http.client.HttpClient
//import java.time.Duration
//import java.util.concurrent.TimeUnit
//import java.util.function.Consumer
//
//
//@RequiredArgsConstructor
//class WebConfig : WebApplicationInitializer {
//
// lateinit var logService : LogService
//
// var factory: DefaultUriBuilderFactory = DefaultUriBuilderFactory()
//
// var httpClient: HttpClient = HttpClient.create()
// .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) // 10초
//
//
// @Throws(ServletException::class)
// override fun onStartup(servletContext: ServletContext) {
// // Spring MVC 프로젝트 설정을 위해 작성하는 클래스의 객체를 생성한다.
// val servletAppContext = AnnotationConfigWebApplicationContext()
//// servletAppContext.register(ServletAppContext::class.java)
//
// // 요청 발생 시 요청을 처리하는 서블릿을 DispatcherServlet으로 설정해준다.
//// val dispatcherServlet = DispatcherServlet(servletAppContext)
//// val servlet = servletContext.addServlet("dispatcher", dispatcherServlet)
////
//// // 부가 설정
//// servlet.setLoadOnStartup(1)
//// servlet.addMapping("/")
//
// // Bean을 정의하는 클래스를 지정한다.
// val rootAppContext = AnnotationConfigWebApplicationContext()
// rootAppContext.register(RootAppContext::class.java)
//
// val listener = ContextLoaderListener(rootAppContext)
// servletContext.addListener(listener)
// }
//
//
//
//
// @Bean
// fun webClient(): WebClient {
// /**
// * 통신시 timeout 세팅
// * - connect, read, write 를 모두 5000ms
// */
//
// val httpClient = HttpClient.create()
// .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
// .responseTimeout(Duration.ofMillis(5000))
// .doOnConnected { conn: Connection ->
// conn.addHandlerLast(ReadTimeoutHandler(5000, TimeUnit.MILLISECONDS))
// .addHandlerLast(WriteTimeoutHandler(5000, TimeUnit.MILLISECONDS))
// }
//
// val webClient = WebClient.builder()
// .baseUrl("https://api.telegram.org/bot7934509464:AAE_xUbICxMdywLGnxo7BkeIqA1nVza4P9w")
// .clientConnector(ReactorClientHttpConnector(httpClient)) //생성한 HttpClient 연결
// //Request Header 로깅 필터
// .filter(
// ExchangeFilterFunction.ofRequestProcessor { clientRequest: ClientRequest ->
// logService.log(">>>>>>>>> REQUEST <<<<<<<<<<")
// logService.log("Request: ${clientRequest.method()} ${clientRequest.url()}")
// clientRequest.headers()
// .forEach { (name: String?, values: MutableList<String?>?) ->
// values.forEach(
// Consumer<String> { value: String? ->
// logService.log(
// "${name} : ${value}"
// )
// })
// }
// Mono.just<ClientRequest>(clientRequest)
// }
// ) //Response Header 로깅 필터
// .filter(
// ExchangeFilterFunction.ofResponseProcessor { clientResponse: ClientResponse ->
// logService.log(">>>>>>>>>> RESPONSE <<<<<<<<<<")
// clientResponse.headers().asHttpHeaders()
// .forEach { (name: String?, values: MutableList<String?>?) ->
// values.forEach(
// Consumer<String> { value: String? ->
// logService.log(
// "${name} ${value}"
// )
// })
// }
// Mono.just<ClientResponse>(clientResponse)
// }
// )
// .defaultHeader("Content-type", "application/x-www-form-urlencoded;charset=utf-8") //기본 헤더설정
// .build()
//
// return webClient
// }
//}

View File

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

View File

@ -1,6 +1,8 @@
package kr.lunaticbum.back.lun.configs package kr.lunaticbum.back.lun.configs
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.MalformedJwtException
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import kr.lunaticbum.back.lun.model.MongoPersistentTokenRepository import kr.lunaticbum.back.lun.model.MongoPersistentTokenRepository
@ -9,6 +11,7 @@ import kr.lunaticbum.back.lun.utils.LogService
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
@ -31,12 +34,21 @@ import org.springframework.web.ErrorResponse
import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import jakarta.servlet.FilterChain
import kr.lunaticbum.back.lun.utils.JwtUtil
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.web.filter.OncePerRequestFilter
import java.security.SignatureException
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity // @PreAuthorize 어노테이션을 사용하기 위해 추가 @EnableMethodSecurity // @PreAuthorize 어노테이션을 사용하기 위해 추가
class SecurityConfig( class SecurityConfig(
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
@ -48,7 +60,7 @@ class SecurityConfig(
fun webSecurityCustomizer(): WebSecurityCustomizer { fun webSecurityCustomizer(): WebSecurityCustomizer {
// 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다. // 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다.
return WebSecurityCustomizer { web -> return WebSecurityCustomizer { web ->
web.ignoring().requestMatchers("/api/images/**", "/images/**") web.ignoring().requestMatchers( "/images/**")
} }
} }
@ -74,15 +86,53 @@ class SecurityConfig(
return source return source
} }
@Bean
@Order(1) // API 보안 설정을 먼저 적용
fun apiFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.securityMatcher("/api/**") // 이 설정은 /api/ 경로에만 적용됨
.csrf { it.disable() }
.cors { it.configurationSource(corsConfigurationSource()) }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용
.anyRequest().authenticated() // 나머지 API는 인증 필요
}
.exceptionHandling { handling ->
handling.authenticationEntryPoint(jwtAuthenticationEntryPoint())
}
// 모든 API 요청 전에 JWT 토큰을 검증하는 필터 추가
.addFilterBefore(JwtAuthenticationFilter(jwtUtil, userManager), UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
@Bean @Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain { fun jwtAuthenticationEntryPoint(): AuthenticationEntryPoint {
return AuthenticationEntryPoint { request, response, authException ->
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.contentType = MediaType.APPLICATION_JSON_VALUE
val body = mapOf(
"status" to HttpServletResponse.SC_UNAUTHORIZED,
"error" to "Unauthorized",
"message" to (authException.message ?: "JWT Authentication Failed"),
"path" to request.servletPath
)
ObjectMapper().writeValue(response.outputStream, body)
}
}
@Bean
@Order(2) // 웹 페이지 보안 설정
fun webFilterChain(http: HttpSecurity): SecurityFilterChain {
http.cors { } http.cors { }
.csrf { csrf -> .csrf { csrf ->
csrf.ignoringRequestMatchers( csrf.ignoringRequestMatchers(
"/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx", "/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx",
"/api/ranks/submit", // 통합 랭킹 API "/api/ranks/submit",
"/puzzle/**", // <-- 이 줄을 추가하세요. "/bums/save/loc.api",
"/puzzle/**",
) )
}.authorizeHttpRequests { auth -> }.authorizeHttpRequests { auth ->
auth auth
@ -93,6 +143,7 @@ class SecurityConfig(
// 2. 공개 GET API 및 페이지 = permitAll // 2. 공개 GET API 및 페이지 = permitAll
.requestMatchers(HttpMethod.GET, .requestMatchers(HttpMethod.GET,
"/api/images/**",
"/", "/home.bs", "/bums/where.bs", "/", "/home.bs", "/bums/where.bs",
"/user/login.bs", "/user/join.bs", "/user/login.bs", "/user/join.bs",
"/blog/viewer/**", "/blog/posts", "/blog/viewer/**", "/blog/posts",
@ -100,7 +151,9 @@ class SecurityConfig(
"/blog/posts/{postId}/comments.bjx", "/blog/comments/{commentId}/replies.bjx", "/blog/posts/{postId}/comments.bjx", "/blog/comments/{commentId}/replies.bjx",
"/blog/categories.bjx", "/blog/hashtags.bjx", "/blog/categories.bjx", "/blog/hashtags.bjx",
"/puzzle/**", "/api/ranks/list", "/licenses", "/puzzle/**", "/api/ranks/list", "/licenses",
"/puzzle/images/**" "/puzzle/images/**",
"/bums/face.bs", // [추가] 사이트 소개 페이지
"/bookmarks/**", // [추가] 북마크 목록 페이지
).permitAll() ).permitAll()
// 3. 공개 POST API = permitAll // 3. 공개 POST API = permitAll
@ -108,10 +161,12 @@ class SecurityConfig(
"/user/login.bjx", "/user/joinUser.bjx", "/user/login.bjx", "/user/joinUser.bjx",
"/api/ranks/submit", "/api/ranks/submit",
"/bums/save/loc.api", "/bums/save/loc.api",
"/puzzle/**", // <-- 이 줄을 추가하세요. "/puzzle/**",
// [수정] 와일드카드를 사용하여 모든 게시물의 좋아요/싫어요 허용 "/tlg/repotToMe.bjx",
"/blog/post/*/like.bjx", "/blog/post/*/like.bjx",
"/blog/post/*/unlike.bjx" "/blog/post/*/unlike.bjx",
"/bookmarks/*/like", // [추가] 북마크 좋아요
"/bookmarks/*/unlike" // [추가] 북마크 싫어요
).permitAll() ).permitAll()
// 4. 'WRITE' 또는 'ADMIN' 권한이 필요한 요청 // 4. 'WRITE' 또는 'ADMIN' 권한이 필요한 요청
@ -124,15 +179,16 @@ class SecurityConfig(
// 5. 'ADMIN' 권한이 필요한 요청 (my_info.html의 관리자 기능) // 5. 'ADMIN' 권한이 필요한 요청 (my_info.html의 관리자 기능)
.requestMatchers( .requestMatchers(
"/user/approve-writer/**", "/user/reject-writer/**", "/user/approve-writer/**", "/user/reject-writer/**",
"/blog/post/*/block", "/blog/post/*/unblock" "/blog/post/*/block", "/blog/post/*/unblock",
"/api/images/*/approve-banner",
"/api/images/*/revoke-banner"
).hasRole("ADMIN") ).hasRole("ADMIN")
// 6. 나머지 모든 요청 = authenticated (인증 필요) // 6. 나머지 모든 요청 = authenticated (인증 필요)
.anyRequest().authenticated() .anyRequest().authenticated()
}.formLogin { form -> }.formLogin { form ->
form.loginPage("/home.bs?action=login") form.loginPage("/home.bs?action=login")
.loginProcessingUrl("/user/login.bs") // 로그인 처리 URL 명시 (선택사항) .loginProcessingUrl("/user/login.bs")
.defaultSuccessUrl("/", true) .defaultSuccessUrl("/", true)
.permitAll() .permitAll()
}.rememberMe { rememberMe -> }.rememberMe { rememberMe ->
@ -143,6 +199,10 @@ class SecurityConfig(
.userDetailsService(userManager) .userDetailsService(userManager)
}.logout { logout -> }.logout { logout ->
logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll() logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll()
}.exceptionHandling { handling ->
handling
.authenticationEntryPoint(unauthorizedEntryPoint) // 인증되지 않은 사용자가 접근 시
.accessDeniedHandler(accessDeniedHandler) // 인증은 되었으나 권한이 없는 사용자가 접근 시
} }
return http.build() return http.build()
} }
@ -187,4 +247,52 @@ class SecurityConfig(
fun bCryptPasswordEncoder(): BCryptPasswordEncoder { fun bCryptPasswordEncoder(): BCryptPasswordEncoder {
return BCryptPasswordEncoder() return BCryptPasswordEncoder()
} }
}
class JwtAuthenticationFilter(
private val jwtUtil: JwtUtil,
private val userManager: UserManager
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val authHeader = request.getHeader("Authorization")
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response)
return
}
try {
val jwt = authHeader.substring(7)
val username = jwtUtil.extractUsername(jwt)
if (SecurityContextHolder.getContext().authentication == null) {
val userDetails = this.userManager.loadUserByUsername(username)
if (jwtUtil.isTokenValid(jwt, userDetails)) {
println("jwtUtil.isTokenValid($jwt, $userDetails)")
val authToken = UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.authorities
)
authToken.details = WebAuthenticationDetailsSource().buildDetails(request)
println("authToken.details >>> ${authToken.details}")
SecurityContextHolder.getContext().authentication = authToken
}
}
} catch (e: ExpiredJwtException) {
println("JWT Error: Token has expired - ${e.message}")
} catch (e: SignatureException) {
println("JWT Error: Signature validation failed - ${e.message}")
} catch (e: MalformedJwtException) {
println("JWT Error: Malformed token - ${e.message}")
} catch (e: Exception) {
println("JWT Error: Could not set user authentication in security context - ${e.message}")
}
filterChain.doFilter(request, response)
println("JWT Token validated")
}
} }

View File

@ -0,0 +1,30 @@
package kr.lunaticbum.back.lun.configs
import io.netty.channel.ChannelOption
import io.netty.handler.timeout.ReadTimeoutHandler
import io.netty.handler.timeout.WriteTimeoutHandler
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.web.reactive.function.client.WebClient
import reactor.netty.http.client.HttpClient
import java.util.concurrent.TimeUnit
@Configuration
class WebClientConfig {
@Bean
fun webClient(): WebClient {
// Netty HttpClient에 타임아웃 설정
val httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) // 연결 타임아웃 5초
.doOnConnected { conn ->
conn.addHandlerLast(ReadTimeoutHandler(5, TimeUnit.SECONDS)) // 읽기 타임아웃 5초
.addHandlerLast(WriteTimeoutHandler(5, TimeUnit.SECONDS)) // 쓰기 타임아웃 5초
}
return WebClient.builder()
.clientConnector(ReactorClientHttpConnector(httpClient))
.build()
}
}

View File

@ -1,21 +1,29 @@
package kr.lunaticbum.back.lun.controllers package kr.lunaticbum.back.lun.controllers
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonParser import com.google.gson.JsonParser
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
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.model.* import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.plainText import kr.lunaticbum.back.lun.utils.plainText
import net.coobird.thumbnailator.Thumbnails import net.coobird.thumbnailator.Thumbnails
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
@ -28,6 +36,7 @@ import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.URLDecoder import java.net.URLDecoder
@ -39,6 +48,7 @@ import java.nio.file.StandardCopyOption
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import javax.imageio.ImageIO import javax.imageio.ImageIO
import kotlin.io.path.exists
// --- API 응답을 위한 DTO (Data Transfer Object) 클래스들 --- // --- API 응답을 위한 DTO (Data Transfer Object) 클래스들 ---
@ -125,7 +135,7 @@ class BlogController(
// 썸네일 생성 및 경로 설정 // 썸네일 생성 및 경로 설정
generateThumbnail(filename, 200) // 너비 200px 썸네일 생성 generateThumbnail(filename, 200) // 너비 200px 썸네일 생성
val thumbFilename = filename.substringBeforeLast(".") + "_thumbnail." + filename.substringAfterLast(".") val thumbFilename = filename.substringBeforeLast(".") + "_thumbnail." + filename.substringAfterLast(".")
post.thumb = "/api/images/$thumbFilename" post.thumb = "/api/images/$thumbFilename?type=thumbnail"
} else { } else {
// 게시물에 이미지가 없는 경우, 기본 썸네일을 지정합니다. // 게시물에 이미지가 없는 경우, 기본 썸네일을 지정합니다.
post.image = null post.image = null
@ -145,42 +155,94 @@ class BlogController(
*/ */
@GetMapping("/api/images/{filename:.+}") @GetMapping("/api/images/{filename:.+}")
@ResponseBody @ResponseBody
fun getImage(@PathVariable filename: String): ResponseEntity<ByteArray> { fun getImage(
@PathVariable filename: String,
@RequestParam(required = false) type: String? // "thumbnail" 같은 타입 요청
): ResponseEntity<ByteArray> {
if (uploadPath.isNullOrBlank()) { if (uploadPath.isNullOrBlank()) {
return ResponseEntity.notFound().build() return ResponseEntity.notFound().build()
} }
try { try {
val imagePath: Path = Paths.get(uploadPath, filename) logService.log("req $filename ")
// 보안: 요청된 파일이 실제 업로드 경로 내에 있는지 확인 (Path Traversal 공격 방지) // 1. 요청 타입이 없으면 원본 이미지를 반환
if (!imagePath.normalize().startsWith(Paths.get(uploadPath).normalize())) { if (type.isNullOrBlank()) {
return ResponseEntity.badRequest().build() val originalPath = Paths.get(uploadPath, filename)
return serveImage(originalPath, filename)
} }
if (Files.exists(imagePath) && Files.isReadable(imagePath)) { // 2. 요청 타입에 따라 캐시 파일 이름과 목표 너비 설정
val imageBytes = Files.readAllBytes(imagePath) val (targetWidth, resizedFilename) = when (type) {
"thumbnail" -> {
// 파일 확장자를 기반으로 적절한 Content-Type 헤더 설정 val baseName = filename.substringBeforeLast(".")
val contentType = when (filename.substringAfterLast('.').lowercase()) { val extension = filename.substringAfterLast(".")
"jpg", "jpeg" -> MediaType.IMAGE_JPEG Pair(400, "${baseName}_thumbnail.${extension}") // 썸네일 너비: 400px
"png" -> MediaType.IMAGE_PNG
"gif" -> MediaType.IMAGE_GIF
else -> MediaType.APPLICATION_OCTET_STREAM
} }
"banner" -> {
return ResponseEntity.ok() val baseName = filename.substringBeforeLast(".")
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"$filename\"") val extension = filename.substringAfterLast(".")
.contentType(contentType) Pair(1200, "${baseName}_banner.${extension}") // 썸네일 너비: 400px
.body(imageBytes) }
// 필요하다면 다른 타입 추가 (예: "medium" -> 800)
else -> Pair(null, null)
} }
if (targetWidth == null || resizedFilename == null) {
val originalPath = Paths.get(uploadPath, filename)
return serveImage(originalPath, filename) // 지원하지 않는 타입이면 원본 반환
}
// 3. 캐시 파일 경로 확인
val resizedPath = Paths.get(uploadPath, resizedFilename)
// 4. 캐시 파일이 이미 존재하면 바로 반환
if (Files.exists(resizedPath)) {
return serveImage(resizedPath, resizedFilename)
}
// 5. 캐시 파일이 없으면 원본을 찾아 리사이즈 후 저장 (캐싱)
val originalPath = Paths.get(uploadPath, filename)
if (!Files.exists(originalPath)) {
return ResponseEntity.notFound().build()
}
Thumbnails.of(originalPath.toFile())
.width(targetWidth)
.keepAspectRatio(true)
.outputQuality(0.85)
.toFile(resizedPath.toFile())
// 6. 새로 생성된 캐시 파일을 반환
return serveImage(resizedPath, resizedFilename)
} catch (e: IOException) { } catch (e: IOException) {
logService.log("Error reading image file: $filename, Error: ${e.message}") logService.log("Error reading image file: $filename, Error: ${e.message}")
// 파일을 읽는 중 오류가 발생하면 500 서버 에러를 반환할 수 있습니다.
return ResponseEntity.internalServerError().build() return ResponseEntity.internalServerError().build()
} }
}
// 파일이 존재하지 않으면 404 Not Found 응답 // 이미지 파일을 읽어 ResponseEntity로 만드는 헬퍼 함수
return ResponseEntity.notFound().build() private fun serveImage(imagePath: Path, filename: String): ResponseEntity<ByteArray> {
if (!Files.exists(imagePath) || !Files.isReadable(imagePath)) {
return ResponseEntity.notFound().build()
}
// 보안: 요청된 파일이 실제 업로드 경로 내에 있는지 확인
if (!imagePath.normalize().startsWith(Paths.get(uploadPath).normalize())) {
return ResponseEntity.badRequest().build()
}
val imageBytes = Files.readAllBytes(imagePath)
val contentType = when (filename.substringAfterLast('.').lowercase()) {
"jpg", "jpeg" -> MediaType.IMAGE_JPEG
"png" -> MediaType.IMAGE_PNG
"gif" -> MediaType.IMAGE_GIF
else -> MediaType.APPLICATION_OCTET_STREAM
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"$filename\"")
.contentType(contentType)
.body(imageBytes)
} }
/** /**
@ -240,7 +302,7 @@ class BlogController(
@ResponseBody @ResponseBody
suspend fun home(): ResultMV { suspend fun home(): ResultMV {
val vm = ResultMV("content/home") val vm = ResultMV("content/home")
val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg" val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg?type=banner"
try { try {
var bannerImagePath: String? = null var bannerImagePath: String? = null
@ -249,9 +311,9 @@ class BlogController(
if (randomImage != null && !randomImage.path.isNullOrBlank()) { if (randomImage != null && !randomImage.path.isNullOrBlank()) {
// 1. 이미지 경로가 예전 방식인지 확인하고 수정합니다. // 1. 이미지 경로가 예전 방식인지 확인하고 수정합니다.
if (randomImage.path.contains("/blog/post/images/")) { if (randomImage.path.contains("/blog/post/images/")) {
bannerImagePath = randomImage.path.replace("/blog/post/images/", "/api/images/") bannerImagePath = randomImage.path.replace("/blog/post/images/", "/api/images/") +"?type=banner"
} else { } else {
bannerImagePath = randomImage.path bannerImagePath = randomImage.path +"?type=banner"
} }
} }
@ -371,6 +433,7 @@ class BlogController(
@GetMapping(value = ["/blog/edit", "/blog/edit/{postId}"]) @GetMapping(value = ["/blog/edit", "/blog/edit/{postId}"])
suspend fun editPost( suspend fun editPost(
@PathVariable(required = false) postId: String?, @PathVariable(required = false) postId: String?,
@RequestParam(required = false) type: String?, // [추가] 'type' 파라미터 받기
@AuthenticationPrincipal userDetails: UserDetails? @AuthenticationPrincipal userDetails: UserDetails?
): ResultMV { ): ResultMV {
if (userDetails == null) { if (userDetails == null) {
@ -394,6 +457,10 @@ class BlogController(
// 사용자의 의도대로 기본 제목을 설정합니다. // 사용자의 의도대로 기본 제목을 설정합니다.
title = "무제(無題) (${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(java.util.Date())})" title = "무제(無題) (${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(java.util.Date())})"
content = "" // 내용은 비워둡니다. content = "" // 내용은 비워둡니다.
if (type == PostType.ABOUT_SITE.name) {
this.postType = PostType.ABOUT_SITE.name
vm.modelMap["pageTitle"] = "사이트 소개글 작성"
}
} }
vm.modelMap["srcPost"] = newPost vm.modelMap["srcPost"] = newPost
vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(newPost) vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(newPost)
@ -551,7 +618,8 @@ class BlogController(
readCount = originalPost.readCount, readCount = originalPost.readCount,
voteCount = originalPost.voteCount, voteCount = originalPost.voteCount,
unlikeCount = originalPost.unlikeCount, unlikeCount = originalPost.unlikeCount,
modifyTime = System.currentTimeMillis() modifyTime = System.currentTimeMillis(),
postType = originalPost.postType // [추가] 기존 postType을 새 버전에 복사
) )
postManager.save(newVersion).map { savedPost -> postManager.save(newVersion).map { savedPost ->
PostSaveResponse(0, "Success", PostIdData(savedPost.id!!)) PostSaveResponse(0, "Success", PostIdData(savedPost.id!!))
@ -669,4 +737,381 @@ class BlogController(
@GetMapping("/licenses") @GetMapping("/licenses")
fun licenses() = ResultMV("content/licenses") fun licenses() = ResultMV("content/licenses")
} }
@RestController
@RequestMapping("/bums")
class BumsPrivate {
@Autowired
lateinit var globalEvv : GlobalEnvironment
@Autowired
lateinit var logService: LogService
@Autowired
lateinit var postManager : PostManager
@Autowired
lateinit var locationService: LocationLogService
@GetMapping("face.bs")
suspend fun aboutMePage(): ResultMV {
val vm = ResultMV("content/about_view") // 소개글 전용 뷰 템플릿 사용
// 'ABOUT_SITE' 타입의 가장 최신 글을 찾아 모델에 추가
val aboutPost = postManager.findLatestAboutPost().awaitSingleOrNull()
if (aboutPost != null) {
vm.modelMap["srcPost"] = aboutPost
vm.modelMap["srcPostJson"] = com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(aboutPost)
vm.setTitle("BUM'sPace 소개")
} else {
// 글이 없을 경우를 대비한 처리
vm.modelMap["srcPost"] = Post(title = "소개글이 아직 작성되지 않았습니다.", content = "")
vm.modelMap["srcPostJson"] = "{}"
vm.setTitle("소개글 없음")
}
return vm
}
@GetMapping("where.bs")
fun where(@RequestParam(value = "page", defaultValue = "0") page: Int) : ResultMV { // (1) page 파라미터 받기
val m = ResultMV("content/private/where")
// (2) Pageable 객체 생성: 현재 페이지(page), 페이지당 20개, ID 역순 정렬 (최신순)
// 예시: 날짜 필드명이 "createdAt"일 경우
val pageable: Pageable = PageRequest.of(page, 30, Sort.by("time").descending())
// (3) 서비스 호출 변경 (List 대신 Page 객체를 반환하는 메서드 호출)
// 참고: locationService에 findAll(Pageable) 메서드가 구현되어 있어야 합니다.
val locationPage: Page<LocationLog> = locationService.findAll(pageable)
// (4) 모델에 Page 객체 전체를 전달
m.modelMap.put("locationPage", locationPage)
m.setTitle("돼지 여기있다요~!!")
return m
}
@ResponseBody
@PostMapping("save/loc.api")
fun login(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity<ResponceResult> {
logService.log("${httpServletRequest.requestURI}")
logService.log(jsonString)
var location : LocationLog? = null
jsonString.plainText().let {
Gson().fromJson<LocationLog>(it, LocationLog::class.java)?.let { model ->
location = model
logService.log(model.toString())
locationService.save(model)
}
}
val responce = ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply {
})
// CoroutineScope(Dispatchers.IO).launch {
// location?.let {
// val client = WebClient.create()
// client.get()
// .uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${it.mAddressLines.first()} 저장")
// .retrieve()
// .bodyToMono(String::class.java).block() ?: "FAIL"
// }
// }
return responce
}
}
@RestController
@RequestMapping("/api/og")
class OpenGraphController(private val logService: LogService) {
// Jsoup 라이브러리를 사용하기 위해 의존성 추가가 필요할 수 있습니다.
// build.gradle.kts 파일에 implementation("org.jsoup:jsoup:1.15.3") 추가
/**
* 전달받은 URL의 Open Graph 메타 태그 정보를 파싱하여 반환합니다.
*/
@GetMapping("/parse")
fun fetchOpenGraphData(@RequestParam url: String): Mono<ResponseEntity<Map<String, String>>> {
return Mono.fromCallable {
try {
// Jsoup으로 URL에 접속하여 HTML 문서를 가져옴
val doc = Jsoup.connect(url).get()
// "og:title", "og:description", "og:image" 메타 태그를 찾음
val title = doc.select("meta[property=og:title]").attr("content")
val description = doc.select("meta[property=og:description]").attr("content")
val imageUrl = doc.select("meta[property=og:image]").attr("content")
// 찾은 정보를 Map에 담아 성공 응답(200 OK)으로 반환
val data = mapOf(
"title" to (title.ifEmpty { doc.title() }), // og:title 없으면 그냥 title 태그 사용
"description" to description,
"thumbnailUrl" to imageUrl
)
ResponseEntity.ok(data)
} catch (e: java.net.SocketTimeoutException) {
// 타임아웃 예외를 명시적으로 처리
logService.log("OG data parsing timed out for URL: $url")
ResponseEntity.status(408).body(mapOf("error" to "요청 시간이 초과되었습니다."))
} catch (e: Exception) {
logService.log("OG data parsing failed for URL: $url, Error: ${e.message}")
// 파싱 실패 시 에러 응답
ResponseEntity.badRequest().body(mapOf("error" to "URL 정보를 가져올 수 없습니다."))
}
}.subscribeOn(Schedulers.boundedElastic()) // I/O 작업을 별도 스레드에서 처리
}
}
@Controller
@RequestMapping("/bookmarks")
class BookmarkController(private val bookmarkService: WebBookmarkService,
private val imageMetaService: ImageMetaService,
private val commentService: CommentService, // [신규 추가] CommentService 주입
private val objectMapper: ObjectMapper // [신규 추가] JSON 처리를 위해 주입
) {
@GetMapping
suspend fun bookmarkListPage(
@RequestParam(value = "page", defaultValue = "0") page: Int,
@AuthenticationPrincipal userDetails: UserDetails?
): ResultMV {
val vm = ResultMV("content/bookmarks") // 북마크 전용 뷰 템플릿
val pageable = PageRequest.of(page, 9) // 한 페이지에 9개씩 (3x3 그리드)
// 서비스 레이어를 호출하여 현재 사용자 권한에 맞는 북마크 목록을 가져옴
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle()
vm.modelMap["bookmarksPage"] = bookmarksPage
vm.setTitle("저장된 페이지 목록")
return vm
}
@PostMapping("/{bookmarkId}/like")
@ResponseBody
fun likeBookmark(@PathVariable bookmarkId: String): Mono<VoteResponse> {
return bookmarkService.incrementVote(bookmarkId).map {
VoteResponse(it.voteCount, it.unlikeCount)
}
}
@PostMapping("/{bookmarkId}/unlike")
@ResponseBody
fun unlikeBookmark(@PathVariable bookmarkId: String): Mono<VoteResponse> {
return bookmarkService.incrementUnlike(bookmarkId).map {
VoteResponse(it.voteCount, it.unlikeCount)
}
}
@GetMapping("/{bookmarkId}/comments")
@ResponseBody
fun getComments(@PathVariable bookmarkId: String): Mono<CommentResponse> {
// 기존 CommentService의 메소드를 그대로 호출.
// 서비스는 ID가 post의 것인지 bookmark의 것인지 구분할 필요 없음.
return commentService.getCommentsForPost(bookmarkId)
.collectList()
.map { comments -> CommentResponse(0, "Success", comments) }
}
@PostMapping("/{bookmarkId}/comments")
@ResponseBody
fun addComment(
@PathVariable bookmarkId: String,
@RequestBody rawPayload: String,
@AuthenticationPrincipal user: UserDetails?
): Mono<CommentResponse> {
val comment = PayloadDecoder.decode(rawPayload, Comment::class.java, objectMapper)
// **핵심**: Comment 객체의 postId 필드에 bookmarkId를 설정
comment.postId = bookmarkId
comment.writer = user?.username ?: "Anonymous"
comment.writeTime = System.currentTimeMillis()
// 기존 CommentService를 사용하여 댓글 저장
return commentService.addComment(comment)
.map { CommentResponse(0, "Success") }
}
@Value("\${image.upload.path}")
private val uploadPath: String? = null
/**
* 북마크 목록을 페이지네이션으로 조회하는 API
* (: GET /api/bookmarks?page=0&size=10)
*/
@GetMapping("/list")
suspend fun getBookmarkList(
@AuthenticationPrincipal userDetails: UserDetails?,
pageable: Pageable
): ResponseEntity<Page<WebBookmark>> {
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle()
return ResponseEntity.ok(bookmarksPage)
}
/**
* 북마크를 저장하는 API
*/
@PostMapping("/save")
fun saveBookmark(
@RequestBody request: Map<String, String>,
@AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<WebBookmark>> {
if (user == null) {
// 이 요청은 인증이 필요하므로 user가 null일 수 없음 (SecurityConfig에서 보장)
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())
}
val url = request["url"] ?: return Mono.just(ResponseEntity.badRequest().build())
val newBookmark = WebBookmark(
userId = user.username,
url = url,
userComment = request["userComment"],
visibility = request["visibility"] ?: "PRIVATE",
metadataStatus = "PENDING"
)
return bookmarkService.saveBookmark(newBookmark)
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }
}
data class BookmarkDataDto(
val url: String,
val userComment: String?,
val visibility: String?
)
@PostMapping("api/with-image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun saveBookmarkWithImage(
@RequestPart("imageFile") imageFile: MultipartFile,
@RequestPart("bookmarkData") bookmarkDataJson: String, // 북마크 데이터는 JSON 문자열로 받음
@AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<WebBookmark>> {
if (user == null || uploadPath.isNullOrBlank()) {
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())
}
// 1. 이미지 파일 저장
val uniqueFilename = "${UUID.randomUUID()}_${imageFile.originalFilename}"
val targetPath = Paths.get(uploadPath, uniqueFilename)
try {
Files.createDirectories(targetPath.parent)
imageFile.transferTo(targetPath.toFile())
} catch (e: Exception) {
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build())
}
// 2. 북마크 데이터 (JSON 문자열)를 DTO 객체로 변환
val bookmarkData: BookmarkDataDto = objectMapper.readValue(bookmarkDataJson)
// 3. WebBookmark 객체 생성
val newBookmark = WebBookmark(
userId = user.username,
url = bookmarkData.url,
userComment = bookmarkData.userComment,
visibility = bookmarkData.visibility ?: "PRIVATE",
metadataStatus = "PENDING",
// 저장된 이미지의 서버 URL을 저장
userSelectedImageUrl = "/api/images/$uniqueFilename"
)
// 4. 북마크 정보 DB에 저장
return bookmarkService.saveBookmark(newBookmark)
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }
}
}
@RestController
@RequestMapping("/api/bookmarks")
class BookmarkApiController(
private val bookmarkService: WebBookmarkService,
private val imageMetaService: ImageMetaService,
private val commentService: CommentService, // [신규 추가] CommentService 주입
private val objectMapper: ObjectMapper, // [신규 추가] JSON 처리를 위해 주입
private val logService: LogService,
) {
@Value("\${image.upload.path}")
private val uploadPath: String? = null
/**
* 북마크 목록을 페이지네이션으로 조회하는 API
* (: GET /api/bookmarks?page=0&size=10)
*/
@GetMapping("/list")
suspend fun getBookmarkList(
@AuthenticationPrincipal userDetails: UserDetails?,
pageable: Pageable
): ResponseEntity<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?
): Mono<ResponseEntity<WebBookmark>> {
logService.log("uploadPath >>> ${uploadPath}")
if (user == null || uploadPath.isNullOrBlank()) {
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())
}
// Gson과 같은 JSON 라이브러리를 사용해 문자열을 DTO 객체로 변환할 수 있습니다.
// val gson = Gson()
// val bookmarkData = gson.fromJson(bookmarkDataJson, BookmarkData::class.java)
println("${user.username} 사용자가 엔드포인트를 호출했습니다.")
println("전달받은 URL: ${/*bookmarkData.url*/ bookmarkDataJson}") // 예시 출력
println("전달받은 이미지: ${imageFile.originalFilename} (크기: ${imageFile.size} 바이트)")
// 1. 이미지 파일 저장
val uniqueFilename = "${UUID.randomUUID()}_${imageFile.originalFilename}"
val targetPath = Paths.get(uploadPath, uniqueFilename)
try {
imageFile.transferTo(targetPath.toFile())
} catch (e: Exception) {
e.printStackTrace()
println("IMAGE TRAN FAIL")
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build())
}
// 2. 북마크 데이터 (JSON 문자열)를 DTO 객체로 변환
val bookmarkData: BookmarkDataDto = objectMapper.readValue(bookmarkDataJson)
// 3. WebBookmark 객체 생성
val newBookmark = WebBookmark(
userId = user.username,
url = bookmarkData.url,
userComment = bookmarkData.userComment,
visibility = bookmarkData.visibility ?: "PRIVATE",
metadataStatus = "PENDING",
// 저장된 이미지의 서버 URL을 저장
userSelectedImageUrl = "/api/images/$uniqueFilename"
)
println("newBookmark ${newBookmark}")
// 4. 북마크 정보 DB에 저장
return bookmarkService.saveBookmark(newBookmark)
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }.apply {
println("OK")
}
}
}

View File

@ -1,80 +0,0 @@
package kr.lunaticbum.back.lun.controllers
import com.google.gson.Gson
import jakarta.servlet.http.HttpServletRequest
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.plainText
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.domain.Page
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
// Spring Data Paging을 위한 Import 추가
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import reactor.core.publisher.Flux
@RestController
@RequestMapping("/bums")
class BumsPrivate {
@Autowired
lateinit var globalEvv : GlobalEnvironment
@Autowired
lateinit var logService: LogService
@Autowired
lateinit var locationService: LocationLogService
@GetMapping("where.bs")
fun where(@RequestParam(value = "page", defaultValue = "0") page: Int) : ResultMV { // (1) page 파라미터 받기
val m = ResultMV("content/private/where")
// (2) Pageable 객체 생성: 현재 페이지(page), 페이지당 20개, ID 역순 정렬 (최신순)
// 예시: 날짜 필드명이 "createdAt"일 경우
val pageable: Pageable = PageRequest.of(page, 30, Sort.by("time").descending())
// (3) 서비스 호출 변경 (List 대신 Page 객체를 반환하는 메서드 호출)
// 참고: locationService에 findAll(Pageable) 메서드가 구현되어 있어야 합니다.
val locationPage: Page<LocationLog> = locationService.findAll(pageable)
// (4) 모델에 Page 객체 전체를 전달
m.modelMap.put("locationPage", locationPage)
m.setTitle("돼지 여기있다요~!!")
return m
}
@ResponseBody
@PostMapping("save/loc.api")
fun login(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity<ResponceResult> {
logService.log("${httpServletRequest.requestURI}")
logService.log(jsonString)
var location : LocationLog? = null
jsonString.plainText().let {
Gson().fromJson<LocationLog>(it, LocationLog::class.java)?.let { model ->
location = model
logService.log(model.toString())
locationService.save(model)
}
}
val responce = ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply {
})
// CoroutineScope(Dispatchers.IO).launch {
// location?.let {
// val client = WebClient.create()
// client.get()
// .uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${it.mAddressLines.first()} 저장")
// .retrieve()
// .bodyToMono(String::class.java).block() ?: "FAIL"
// }
// }
return responce
}
}

View File

@ -1,48 +0,0 @@
package kr.lunaticbum.back.lun.controllers
import kr.lunaticbum.back.lun.model.GameRank
import kr.lunaticbum.back.lun.model.GameRankService
import kr.lunaticbum.back.lun.model.GameType
import kr.lunaticbum.back.lun.model.UnifiedRankDto
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@RestController
@RequestMapping("/api/ranks") // 모든 랭킹 API는 이 공통 경로를 사용
class GameRankController(private val gameRankService: GameRankService) {
/**
* [수정] 모든 게임을 위한 통합 랭킹 등록 엔드포인트 (에러 처리 추가)
*/
@PostMapping("/submit")
fun submitUnifiedRank(@RequestBody rankDto: UnifiedRankDto): Mono<ResponseEntity<Any>> {
return gameRankService.submitRank(rankDto)
.map { savedRank -> ResponseEntity.ok<Any>(savedRank) }
.onErrorResume(IllegalArgumentException::class.java) { e ->
// 서비스에서 이름 중복 예외가 발생하면 409 Conflict 상태와 에러 메시지를 반환
Mono.just(ResponseEntity.status(HttpStatus.CONFLICT).body(e.message))
}
.onErrorResume {
// 기타 예외는 500 Internal Server Error로 처리
Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("랭킹 등록 중 서버 오류가 발생했습니다."))
}
}
/**
* 모든 게임을 위한 통합 랭킹 조회 엔드포인트
* : /api/ranks/list?gameType=SUDOKU&contextId=123
* : /api/ranks/list?gameType=GAME_2048
*/
@GetMapping("/list")
fun getUnifiedRanks(
@RequestParam gameType: GameType,
@RequestParam contextId: String? = null
): Flux<GameRank> {
// contextId가 "null" 문자열로 오는 경우를 방지하여 실제 null로 처리
val effectiveContextId = if (contextId == "null") null else contextId
return gameRankService.getRanks(gameType, effectiveContextId)
}
}

View File

@ -0,0 +1,72 @@
package kr.lunaticbum.back.lun.controllers
import kr.lunaticbum.back.lun.model.MessageService
import kr.lunaticbum.back.lun.model.ResultMV
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Mono
// 쪽지 전송 시 Body 데이터를 받기 위한 DTO
data class MessageRequest(val receiverId: String, val title: String, val content: String)
@RestController
@RequestMapping("/messages")
@PreAuthorize("isAuthenticated()") // 모든 메시지 기능은 로그인한 사용자만 가능
class MessageController(private val messageService: MessageService) {
/**
* 읽은 쪽지 개수를 확인하는 API (헤더 아이콘 표시용)
*/
@GetMapping("/unread-count")
fun getUnreadCount(@AuthenticationPrincipal userDetails: UserDetails): Mono<Map<String, Long>> {
return messageService.getUnreadMessageCount(userDetails.username)
.map { count -> mapOf("count" to count) }
}
/**
* 쪽지함 페이지를 보여주는 핸들러
*/
@GetMapping
fun getInboxPage(@AuthenticationPrincipal userDetails: UserDetails): Mono<ResultMV> {
val vm = ResultMV("content/messages/inbox")
return messageService.getMessagesForUser(userDetails.username)
.collectList()
.map { messages ->
vm.modelMap["messages"] = messages
vm.modelMap["pageTitle"] = "내 쪽지함"
vm
}
}
/**
* 쪽지를 보내는 API
*/
@PostMapping("/send")
fun sendMessage(
@AuthenticationPrincipal userDetails: UserDetails,
@RequestBody request: MessageRequest
): Mono<ResponseEntity<String>> {
return messageService.sendMessage(
userDetails.username,
request.receiverId,
request.title,
request.content
).map { ResponseEntity.ok("메시지를 보냈습니다.") }
}
/**
* 특정 쪽지를 읽음 처리하는 API
*/
@PostMapping("/{messageId}/read")
fun markAsRead(
@PathVariable messageId: String,
@AuthenticationPrincipal userDetails: UserDetails
): Mono<ResponseEntity<Void>> {
return messageService.markMessageAsRead(messageId, userDetails.username)
.map { ResponseEntity.ok().build<Void>() }
.defaultIfEmpty(ResponseEntity.notFound().build())
}
}

View File

@ -4,11 +4,14 @@ import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import import kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.core.io.UrlResource import org.springframework.core.io.UrlResource
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.ui.Model import org.springframework.ui.Model
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.nio.file.Paths import java.nio.file.Paths
/** /**
@ -233,4 +236,42 @@ class PuzzleController(
val vm = ResultMV("content/puzzle/upload") val vm = ResultMV("content/puzzle/upload")
return vm return vm
} }
}
@RestController
@RequestMapping("/api/ranks") // 모든 랭킹 API는 이 공통 경로를 사용
class GameRankController(private val gameRankService: GameRankService) {
/**
* [수정] 모든 게임을 위한 통합 랭킹 등록 엔드포인트 (에러 처리 추가)
*/
@PostMapping("/submit")
fun submitUnifiedRank(@RequestBody rankDto: UnifiedRankDto): Mono<ResponseEntity<Any>> {
return gameRankService.submitRank(rankDto)
.map { savedRank -> ResponseEntity.ok<Any>(savedRank) }
.onErrorResume(IllegalArgumentException::class.java) { e ->
// 서비스에서 이름 중복 예외가 발생하면 409 Conflict 상태와 에러 메시지를 반환
Mono.just(ResponseEntity.status(HttpStatus.CONFLICT).body(e.message))
}
.onErrorResume {
// 기타 예외는 500 Internal Server Error로 처리
Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("랭킹 등록 중 서버 오류가 발생했습니다."))
}
}
/**
* 모든 게임을 위한 통합 랭킹 조회 엔드포인트
* : /api/ranks/list?gameType=SUDOKU&contextId=123
* : /api/ranks/list?gameType=GAME_2048
*/
@GetMapping("/list")
fun getUnifiedRanks(
@RequestParam gameType: GameType,
@RequestParam contextId: String? = null
): Flux<GameRank> {
// contextId가 "null" 문자열로 오는 경우를 방지하여 실제 null로 처리
val effectiveContextId = if (contextId == "null") null else contextId
return gameRankService.getRanks(gameType, effectiveContextId)
}
} }

View File

@ -242,78 +242,7 @@ class Telegram {
} }
} }
} else if(msg.text?.startsWith("/") == true) { } else if(msg.text?.startsWith("/") == true) {
// msg.text?.split(" ")?.let { cmds ->
// cmds[0].let { cmd ->
// when(cmd.trim()) {
// "/reqGapiKeys" -> {
// sendSimpleMsg(globalEvv.telegramBotKey!!,globalEvv.telegramMyId!!,"${msg.from!!.id.toString()}님이 서비스 키를 요첨항./setGaipKeys {key}")
// }
// "/setGaipKeys" -> {
// var pref = Preferences.userNodeForPackage(Telegram::class.java)
// pref.put("GAPI_KEY".plus("_").plus(msg.from!!.id.toString()), cmds[1])
// pref.sync()
// println("test prefKey ${"GAPI_KEY".plus("_").plus(msg.from!!.id.toString())}")
// println("test prefKey ${cmds[1]}")
// println("test prefKey ${pref.get("GAPI_KEY".plus("_").plus(msg.from!!.id.toString()),"")}")
//
// }
// "/get" ->{}
// "/jf" ->{
//// CoroutineScope(Dispatchers.IO).launch {
//// logService.log("${cmd} Start ${cmds[1]}")
//// String.format(String(Base64.getMimeDecoder().decode("aHR0cHM6Ly9qYXZtb3N0LnRvL3NlYXJjaC9tb3ZpZS8lcw==".toByteArray())),cmds[1]).getJ().let { doc -> FeedParseManager.parse(doc,rssDataService) }
//// logService.log("${cmd} END ${cmds[1]}")
//// }
//// CoroutineScope(Dispatchers.IO).launch {
//// logService.log("on Cmd JF with SO")
//// logService.log("${cmd} Start ${cmds[1]}")
//// String.format(String(Base64.getMimeDecoder().decode("aHR0cHM6Ly9rcjcwLnNvZ2lybC5zby8/cz0lcw==".toByteArray())),cmds[1]).getJ().let { doc -> FeedParseManager.parse(doc,rssDataService)}
//// logService.log("${cmd} END ${cmds[1]}")
//// }
// }
// "/lama" -> {
// val req = BumlamaReq(msg.text!!.replace(cmd,""))
// CoroutineScope(Dispatchers.IO).launch {
//
// val fullUrl =
// "https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=lama 에게 전송 ${req.reqMsg}"
// logService.log("fullUrl >>> ${fullUrl}")
// WebClient.create().get()
// .uri(fullUrl)
// .retrieve()
// .bodyToMono(String::class.java).block()
// }
// CoroutineScope(Dispatchers.IO).launch {
// logService.log("${cmd} Start ${cmds[1]}")
//// msg.chat?.id
// try {
// val client = WebClient.create()
// client.post()
// .uri(lamaGenerated)
// .body(BodyInserters.fromValue(Gson().toJson(req)))
// .retrieve()
// .bodyToMono(String::class.java).timeout(Duration.ofSeconds(6000L)).block()?.let { result ->
// Gson().fromJson(result, BumlamaResp::class.java)?.let { sss ->
// println(Gson().toJson(sss))
// val fullUrl = "https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${sss.response}"
// logService.log("fullUrl >>> ${fullUrl}")
// WebClient.create().get()
// .uri(fullUrl)
// .retrieve()
// .bodyToMono(String::class.java).block() ?: "FAIL"
// }
// }
// } catch (e: Exception) {
// e.printStackTrace()
// }
//
// logService.log("${cmd[0]} END ${cmd[1]}")
// }
// }
// else -> {}
// }
// }
// }
} else if (msg.text?.contains("어디") == true) { } else if (msg.text?.contains("어디") == true) {
msg.from?.id?.let { sendMsg(it.toString()) } msg.from?.id?.let { sendMsg(it.toString()) }
} else { } else {
@ -341,18 +270,6 @@ class Telegram {
return "Success" return "Success"
} }
// fun chatClient(): ChatClient {
// return OllamaChatClient(OllamaApi("https://lama.lunaticbum.kr"))
// .withDefaultOptions(
// OllamaOptions.create()
// .withModel("phi4:14b")
// .withNumThread(5)
// .withSeed(5)
// .withTemperature(0.9f)
// )
// }
@Autowired @Autowired
lateinit var lama : Lama lateinit var lama : Lama
@ -361,32 +278,6 @@ class Telegram {
@GetMapping("query/{path}") @GetMapping("query/{path}")
fun googleQueryTest(@PathVariable path: String): String { fun googleQueryTest(@PathVariable path: String): String {
var originalQuery = path var originalQuery = path
// POST /collections
//
// Content-Type: application/json
//
// {
// "name": "movies",
// "vector_size": 3072,
// "distance": "Cosine"
// }
// println(lama.makeCollection())
// val gSearch = "https://psn.lunaticbum.kr/search?q=${originalQuery?.replace("오늘", SimpleDateFormat("yyyMMdd").format(Date()))}&language=auto&time_range=month&safesearch=0&categories=general&format=json"
// println("gSearch >>> ${gSearch}")
// var additionalInfo = StringBuffer()
// additionalInfo.append("참고자료")
// var idx = 0
// WebClient.create().get()
// .uri(gSearch)
// .retrieve()
// .bodyToMono(SearXng::class.java).timeout(Duration.ofMinutes(20L)).block()?.let { gsResult ->
// gsResult.results?.filter { it.score > 0.5}?.forEach {
// additionalInfo.append(idx).append(":").append(Gson().toJson(it))
// idx += 1
// }
// }
CoroutineScope(Dispatchers.IO).async { CoroutineScope(Dispatchers.IO).async {
lama.generateResponse(originalQuery.replace("오늘","오늘(${SimpleDateFormat("yyyy-MM-dd").format(Date())})")) lama.generateResponse(originalQuery.replace("오늘","오늘(${SimpleDateFormat("yyyy-MM-dd").format(Date())})"))
} }

View File

@ -37,7 +37,8 @@ import java.time.format.DateTimeFormatter
import java.util.* import java.util.*
import javax.naming.AuthenticationException import javax.naming.AuthenticationException
import kotlin.collections.emptyList import kotlin.collections.emptyList
import kr.lunaticbum.back.lun.model.Message
import reactor.core.publisher.Flux
@RestController @RestController
@RequestMapping("/user") @RequestMapping("/user")
@ -47,8 +48,10 @@ class UserController(
private val postManager: PostManager, private val postManager: PostManager,
private val commentService: CommentService, private val commentService: CommentService,
private val gameRankService: GameRankService, // [신규 추가] GameRankService 의존성 주입 private val gameRankService: GameRankService, // [신규 추가] GameRankService 의존성 주입
private val messageService: MessageService,
private val webBookmarkService: WebBookmarkService,
private val imageMetaService: ImageMetaService private val imageMetaService: ImageMetaService
) { ) {
@ -267,6 +270,14 @@ class UserController(
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()
// [신규] 받은 쪽지와 보낸 쪽지를 모두 조회하고, 시간순으로 합쳐서 모델에 추가합니다.
val receivedMessages : List<Message> = (messageService.getMessagesForUser(username).collectList().block() ?: emptyList()) as List<Message>
val sentMessages : List<Message> = (messageService.getSentMessagesByUser(username).collectList().block() ?: emptyList()) as List<Message>
// 두 리스트를 합친 후, 최신순으로 정렬합니다.
val allMessages = (receivedMessages + sentMessages).sortedByDescending { it.timestamp }
vm.modelMap["myMessages"] = allMessages
// 4. [신규 추가] 내가 남긴 게임 랭킹 조회 (최신 20개) // 4. [신규 추가] 내가 남긴 게임 랭킹 조회 (최신 20개)
val myRanks = gameRankService.getRanksByPlayer(username).take(20).collectList().block() val myRanks = gameRankService.getRanksByPlayer(username).take(20).collectList().block()
vm.modelMap["myRanks"] = myRanks ?: emptyList() vm.modelMap["myRanks"] = myRanks ?: emptyList()
@ -282,7 +293,8 @@ class UserController(
vm.modelMap["permissionRequests"] = userManager.findUsersRequestingWritePermission().collectList().block() vm.modelMap["permissionRequests"] = userManager.findUsersRequestingWritePermission().collectList().block()
vm.modelMap["allRecentPosts"] = postManager.findAllVersionsPaginated(PageRequest.of(0, 20)).block() // 모든 글 조회 vm.modelMap["allRecentPosts"] = postManager.findAllVersionsPaginated(PageRequest.of(0, 20)).block() // 모든 글 조회
vm.modelMap["allImages"] = imageMetaService.getAllImages().collectList().block() vm.modelMap["allImages"] = imageMetaService.getAllImages().collectList().block()
// [신규 추가] 사이트 소개글 히스토리를 모델에 추가
vm.modelMap["aboutPostHistory"] = postManager.findAboutPostHistory().collectList().block()
} }
return vm return vm
@ -292,9 +304,33 @@ class UserController(
@PostMapping("/request-write") @PostMapping("/request-write")
@ResponseBody @ResponseBody
fun requestWrite(@AuthenticationPrincipal userDetails: UserDetails?): Mono<ResponseEntity<String>> { fun requestWrite(@AuthenticationPrincipal userDetails: UserDetails?): Mono<ResponseEntity<String>> {
if (userDetails == null) return Mono.just(ResponseEntity.status(401).build()) if (userDetails == null) {
return Mono.just(ResponseEntity.status(401).build())
}
return userManager.requestWritePermission(userDetails.username) return userManager.requestWritePermission(userDetails.username)
.map { ResponseEntity.ok("요청이 완료되었습니다.") } .map { savedUser -> // DB에 저장된 유저 정보를 받음
// --- 텔레그램 알림 전송 로직 ---
try {
val message = "[권한 요청] 사용자 '${savedUser.user_id}'님이 글쓰기 권한을 요청했습니다."
val client = WebClient.create()
client.get()
.uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${message}")
.retrieve()
.bodyToMono(String::class.java)
.subscribe( // non-blocking (Fire-and-Forget) 방식으로 호출
{ response -> logService.log("Telegram notification sent successfully for user ${savedUser.user_id}. Response: $response") },
{ error -> logService.log("Error sending Telegram notification for user ${savedUser.user_id}: ${error.message}") }
)
} catch (e: Exception) {
// WebClient 생성 또는 설정 중 발생할 수 있는 동기적 예외 처리
logService.log("Exception while preparing Telegram notification for user ${savedUser.user_id}: ${e.message}")
}
// --- 알림 로직 끝 ---
// 기존과 동일하게 클라이언트에게 성공 응답을 반환
ResponseEntity.ok("요청이 완료되었습니다.")
}
.defaultIfEmpty(ResponseEntity.status(404).body("사용자를 찾을 수 없습니다.")) .defaultIfEmpty(ResponseEntity.status(404).body("사용자를 찾을 수 없습니다."))
} }
@ -319,4 +355,77 @@ class UserController(
.defaultIfEmpty(ResponseEntity.notFound().build()) .defaultIfEmpty(ResponseEntity.notFound().build())
} }
/**
* 북마크 저장을 위해 클라이언트로부터 받는 데이터를 담는 DTO
*/
data class BookmarkSaveRequest(
val url: String,
val title: String?,
val description: String?,
val thumbnailUrl: String?,
val userComment: String?,
val visibility: String?
)
@PostMapping("/bookmarks/save")
@ResponseBody
fun saveBookmark(
// [수정] DTO 대신 URL과 코멘트만 간단히 받도록 변경
@RequestBody request: Map<String, String>,
@AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<WebBookmark>> {
if (user == null) {
return Mono.just(ResponseEntity.status(401).build())
}
val url = request["url"] ?: return Mono.just(ResponseEntity.badRequest().build())
// [수정] URL과 사용자 정보, PENDING 상태만으로 북마크 객체를 생성하여 저장
val newBookmark = WebBookmark(
userId = user.username,
url = url,
userComment = request["userComment"],
visibility = request["visibility"] ?: Visibility.PRIVATE.name,
userSelectedImageUrl = request["userSelectedImageUrl"],
metadataStatus = MetadataStatus.PENDING.name // 초기 상태는 PENDING
)
// DB에 저장하고 즉시 사용자에게 성공 응답을 반환
return webBookmarkService.saveBookmark(newBookmark)
.map { savedBookmark -> ResponseEntity.ok(savedBookmark) }
}
}
// --- API 요청/응답을 위한 DTO ---
data class LoginRequest(val userId: String, val userPw: String)
data class LoginResponse(val token: String)
@RestController
@RequestMapping("/api/auth")
class AuthController(
private val authenticationManager: AuthenticationManager,
private val userManager: UserManager,
private val jwtUtil: JwtUtil,
private val logService: LogService
) {
@PostMapping("/login")
fun createAuthenticationToken(@RequestBody loginRequest: LoginRequest): ResponseEntity<*> {
// 1. 사용자 인증
authenticationManager.authenticate(
UsernamePasswordAuthenticationToken(loginRequest.userId, loginRequest.userPw)
)
logService.log("loginRequest.userId >>> ${loginRequest.userId}")
// 2. 인증 성공 시 UserDetails 객체 로드
val userDetails = userManager.loadUserByUsername(loginRequest.userId)
logService.log("userDetails.username >>> ${userDetails.username}")
// 3. JWT 토큰 생성
val token = jwtUtil.generateToken(userDetails)
// 4. 토큰을 응답으로 반환
return ResponseEntity.ok(LoginResponse(token))
}
} }

View File

@ -1,2 +0,0 @@
package kr.lunaticbum.back.lun.model

View File

@ -1,451 +1,28 @@
package kr.lunaticbum.back.lun.model //package kr.lunaticbum.back.lun.model
//
import com.google.gson.Gson //import com.google.gson.Gson
import kr.lunaticbum.back.lun.configs.GlobalEnvironment //import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.utils.LogService //import kr.lunaticbum.back.lun.utils.LogService
import lombok.AllArgsConstructor //import lombok.AllArgsConstructor
import lombok.Data //import lombok.Data
import lombok.NoArgsConstructor //import lombok.NoArgsConstructor
import org.bson.codecs.pojo.annotations.BsonIgnore //import org.bson.codecs.pojo.annotations.BsonIgnore
import org.jsoup.Jsoup //import org.jsoup.Jsoup
import org.springframework.beans.factory.annotation.Autowired //import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.annotation.Id //import org.springframework.data.annotation.Id
import org.springframework.data.domain.Page //import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable //import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort //import org.springframework.data.domain.Sort
import org.springframework.data.mongodb.core.mapping.Document //import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.Query //import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository //import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository //import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service //import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient //import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Flux //import reactor.core.publisher.Flux
import reactor.core.publisher.Mono //import reactor.core.publisher.Mono
import java.text.SimpleDateFormat //import java.text.SimpleDateFormat
import java.time.Duration //import java.time.Duration
import java.util.* //import java.util.*
import org.springframework.data.domain.PageImpl //import org.springframework.data.domain.PageImpl
import java.time.format.DateTimeFormatter //import java.time.format.DateTimeFormatter
class BumsPrivate {
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "LocationLog")
class LocationLog {
var mFeatureName: String? = null
var mAddressLines: ArrayList<String> = arrayListOf()
var mAdminArea: String? = null
var mSubAdminArea: String? = null
var mLocality: String? = null
var mSubLocality: String? = null
var mThoroughfare: String? = null
var mSubThoroughfare: String? = null
var mPremises: String? = null
var mPostalCode: String? = null
var mCountryCode: String? = null
var mCountryName: String? = null
var mLatitude = 0.0
var mLongitude = 0.0
var mPhone: String? = null
var timeString : String? = null
var mUrl: String? = null
var time : Long = 0L
var userId : String? = null
var bettween : String? = null
val displayTime: String
get() {
// 1. timeString 값이 존재하고 비어있지 않으면, 그 값을 사용한다.
if (!this.timeString.isNullOrBlank()) {
return this.timeString!!
}
// 2. timeString이 없을 경우, 원본 logTime 객체가 있다면 포맷팅해서 반환한다.
if (this.time != null) {
// 원하는 날짜/시간 포맷 정의
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return formatter.format(Date(this.time))
}
// 3. 둘 다 없으면 "시간 없음"을 반환한다.
return "[시간 정보 없음]"
}
override fun toString(): String {
val buffer = StringBuffer()
buffer.append(mFeatureName).append("|").append("\n")
buffer.append(mAddressLines.joinToString(" , ")).append("|").append("\n")
buffer.append(mAdminArea).append("|").append("\n")
buffer.append(mSubAdminArea).append("|").append("\n")
buffer.append(mLocality).append("|").append("\n")
buffer.append(mSubLocality).append("|").append("\n")
buffer.append(mThoroughfare).append("|").append("\n")
buffer.append(mSubThoroughfare).append("|").append("\n")
buffer.append(mPremises).append("|").append("\n")
buffer.append(mPostalCode).append("|").append("\n")
buffer.append(mCountryCode).append("|").append("\n")
buffer.append(mCountryName).append("|").append("\n")
buffer.append(mLatitude).append("|").append("\n")
buffer.append(mLongitude).append("|").append("\n")
buffer.append(mPhone).append("|").append("\n")
buffer.append(mUrl).append("|").append("\n")
return buffer.toString()
}
}
@Repository
interface LocationLogRepository : ReactiveMongoRepository<LocationLog, String> {
@Query("{ 'time' : { \$gte: ?0 } }")
fun findRecent(since: Long, sort: Sort): Flux<LocationLog>
// @Query("SELECT l FROM LocationLog l WHERE l.timeString >= :since ORDER BY l.timeString DESC")
// fun findRecent(@Param("since") since: String): Flux<LocationLog>
fun findTop30ByOrderByTimeDesc(): Flux<LocationLog>
fun findAllBy() : Mono<LocationLog>
fun findFirstByOrderByTimeDesc() : Mono<LocationLog>
fun findFirstByUserIdOrderByTimeDesc(userId: String) : Mono<LocationLog>
fun save(log: LocationLog): Mono<LocationLog>
}
interface LocationService {
}
@Service
class LocationLogService : LocationService {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var logRepository: LocationLogRepository
fun findAll(pageable: Pageable): Page<LocationLog> {
// 1. 페이지 데이터 가져오기 (비동기 -> 동기 'block()')
// Flux 스트림에 정렬, 스킵, 제한을 적용한 뒤 List로 변환합니다.
val items: List<LocationLog> = logRepository
.findAll(pageable.getSort())
.skip(pageable.getOffset())
.take(pageable.getPageSize().toLong())
.collectList() // Flux<T>를 Mono<List<T>>로 변환
.block() ?: emptyList() // Mono를 block()하여 실제 List<T>를 추출
// 2. 전체 카운트 가져오기 (페이지네이션 계산을 위해 별도 쿼리 필요)
val totalCount: Long = logRepository
.count() // Flux<Long> (count)
.block() ?: 0L // Mono를 block()하여 실제 Long 값을 추출
// 3. Page 구현체(PageImpl)로 조합하여 반환
return PageImpl(items, pageable, totalCount)
}
fun find10() : List<LocationLog> {
val sinceMills = System.currentTimeMillis() - ((24 * 60 * 60 * 1000) * 100)
println("sinceMills >> $sinceMills")
val sort = Sort.by(Sort.Direction.DESC, "time") // 오름차순 정렬
// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
// val since = LocalDateTime.now().minusHours(24).format(formatter)
// println("since >> $since")
val flux = filterByDistanceReactive(logRepository.findRecent(sinceMills,sort), 10.0)
return flux.collectList().block(Duration.ofSeconds(30)) ?: listOf()
}
fun getLocationLog() : LocationLog? {
return logRepository.findFirstByOrderByTimeDesc().block()
}
fun getLocationLogBy(userId : String) : LocationLog? {
return logRepository.findFirstByOrderByTimeDesc().block()
}
fun filterByDistanceReactive(flux: Flux<LocationLog>, minDistanceMeter: Double): Flux<LocationLog> {
return flux
.buffer(2, 1)
.filter { pair ->
if (pair.size < 2) true
else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude) >= minDistanceMeter
}
.map { pair ->
val distance = if (pair.size < 2) 0.0 else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude)
val base = pair[0]
println("base >>> ${base.time} ${base.timeString}")
base.bettween = String.format("%.2f m", distance) // 소수점 두자리까지 거리 표시
base
}
}
// Haversine 거리계산 함수 (단위:m)
fun haversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val R = 6371000.0 // 지구 반지름(m)
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
fun save(log: LocationLog) {
println("saved msg before ${log}")
logRepository.save(log).subscribe( { println("saved msg after ${it}") },{e -> e.printStackTrace()},{
println("saved msg comp")
})
}
}
interface RssDataInterface {
fun title() : String
fun thumbnailUrl() : String
fun originPage() : String
fun description() : String
fun pubDate() : Long
fun category() : RssDataType
fun getCho() : String?
}
enum class RssDataType {
NO_DATA,
YOUTUBE,
NewsFeed,
GURU,
Most,
TAGS,
REDDIT,
REDDIT_nsfw,
Dotax,
FmKorae,
DcInside,
RuliWeb,
Clien,
TheQoo,
Arca;
// fun getResId() = when (this) {
// YOUTUBE -> R.drawable.youtube
// REDDIT, REDDIT_nsfw -> R.drawable.reddit
// Dotax -> R.drawable.daum
// FmKorae -> R.drawable.fmk
// DcInside -> R.drawable.dcinside
// Arca -> R.drawable.arca
// else -> {
// 0
// }
// }
fun defaultImgSize() = when (this) {
YOUTUBE -> 200
REDDIT_nsfw,GURU,Most -> 360
else -> { 120 }
}
// fun getDefaultVisibiliy() = when (this) {
// REDDIT_nsfw,GURU,Most,NewsFeed -> View.GONE
// else -> { View.VISIBLE }
// }
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "RssData")
class RssData : RssDataInterface {
@Id
var originPage : String? = null
var title : String? = null
var description : String? = null
var thumbnail : String? = null
var pubDate : Long = 0L
var category : String? = null
var chosung : String? = null
@BsonIgnore
var mRssDataType : RssDataType? = null
override fun title(): String {
return when(category()){
RssDataType.NewsFeed -> {
if(title?.length ?: 0 > 30) title?.substring(0,30).plus("...") else title ?: ""
}
else -> title ?: ""
}.apply {
// chosung = JamoUtils.split(this).joinToString("")
}
}
override fun thumbnailUrl(): String {
return thumbnail ?: ""
}
override fun originPage(): String {
return originPage ?: ""
}
override fun description(): String {
return when(category()){
RssDataType.YOUTUBE -> {
if(description?.contains("게시자") == true) description!!.split("게시자")[0] else description ?: ""
}
RssDataType.NewsFeed -> {
category().name
}
else -> description.plus(" / ").plus(category().name)
}
}
override fun pubDate(): Long {
return pubDate
}
override fun category(): RssDataType {
if (mRssDataType == null)
mRssDataType = RssDataType.valueOf(category!!)
return mRssDataType!!
}
override fun getCho(): String? {
return chosung
}
}
val USAGT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15"
fun String.getJ() = Jsoup.connect(this).userAgent(USAGT).get()
object FeedParseManager {
val parsers = listOf<SoInterface>(QVZTb2dpcmw,SkFWTW9zdA)
fun parse(doc : org.jsoup.nodes.Document, service: RssDataService) {
try {
parsers.filter { doc.title().contains(it.getName()) }.first()?.let {
it.parse(doc,service)
}
} catch (e : Exception) {
e.printStackTrace()
}
}
}
interface SoInterface{
fun getName() : String
fun parse(doc : org.jsoup.nodes.Document,service: RssDataService)
}
object QVZTb2dpcmw : SoInterface {
override fun getName(): String {
return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray()))
}
override fun parse(doc : org.jsoup.nodes.Document, service : RssDataService) {
var lists = arrayListOf<RssData>()
doc.getElementsByTag("article").forEach { article ->
val title = article.getElementsByTag("a").get(0).attr("title")
val href = article.getElementsByTag("a").get(0).attr("href")
val img = article.getElementsByTag("img").get(0).attr("data-src")
service.save(RssData().apply {
this.originPage = href
this.title = title
this.description = "Sogirl"
this.thumbnail = img
this.pubDate = Date().time
this.category = RssDataType.GURU.name
}) {
// CoroutineScope(Dispatchers.IO).launch {
// service.sendMsg("${title}\n${img}\n${href}")
// }
}
}
// lists.map {
// service.sendMsg("${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}")
// }
}
}
object SkFWTW9zdA : SoInterface {
var dmy = SimpleDateFormat("dd-MM-yyyy")
override fun getName(): String {
return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray()))
}
override fun parse(doc: org.jsoup.nodes.Document, service: RssDataService) {
var lists = arrayListOf<RssData>()
doc.getElementsByClass("card").forEach { card ->
var thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("src") else ""
if (thumb.contains("No+Poster")) thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("data-src") else thumb
var model = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("alt") else ""
if(card.getElementsByClass("card-block").size > 0) if(card.getElementsByClass("card-block").size > 0) {
val link = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("href")
val title = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("title")
val date = card.getElementsByTag("span").get(0).text()
service.save(RssData().apply {
lists.add(this)
description = model
thumbnail = thumb
originPage = link
this.title = title
category = RssDataType.Most.name
try {
pubDate = dmy.parse(date).time
}catch (e : Exception) {e.printStackTrace()}
}){
// CoroutineScope(Dispatchers.IO).launch {
// service.sendMsg("${title}\n${thumb}\n${link}")
// }
}
}
}
// service.sendMsg(lists.map {
// "${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}\n"
// }.joinToString(" \n "))
}
}
@Repository
interface RssDataRepository : ReactiveMongoRepository<RssData, String> {
fun findFirstByOriginPageEquals(originPage : String): Mono<RssData>
fun findAllByOrderByPubDate() : Mono<List<RssData>>
fun save(log: RssData): Mono<RssData>
}
@Service
class RssDataService {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var rssDataRepository: RssDataRepository
fun hasItem(originPage : String) {
}
fun getLocationLog() : List<RssData>? {
return rssDataRepository.findAllByOrderByPubDate().block()
}
fun save(log: RssData, callback : (Boolean)->Unit) {
println("saved msg before ${Gson().toJson(log)}")
log.originPage?.let {
if(rssDataRepository.findFirstByOriginPageEquals(it).block() == null) {
rssDataRepository.save(log)
.subscribe({ println("saved msg after ${it}") }, { e -> e.printStackTrace() }, {
println("saved msg comp")
callback(true)
})
} else {
println("있어???")
}
}
}
@Autowired
lateinit var globalEvv : GlobalEnvironment
suspend fun sendMsg(data : String) {
val client = WebClient.create()
client.get()
.uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${data}")
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
}
}

View File

@ -11,140 +11,140 @@ import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service 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
//
/** ///**
* 모든 게임의 랭킹을 저장하는 통합 모델 // * 모든 게임의 랭킹을 저장하는 통합 모델
*/ // */
@Document(collection = "game_ranks") //@Document(collection = "game_ranks")
data class GameRank( //data class GameRank(
@Id // @Id
val id: String? = null, // val id: String? = null,
val gameType: GameType, // 게임 종류 (2048, SUDOKU, SPIDER 등) // val gameType: GameType, // 게임 종류 (2048, SUDOKU, SPIDER 등)
val contextId: String?, // 게임의 세부 ID (예: 스도쿠 퍼즐 Key, 스파이더 난이도, 노노그램 퍼즐 ID) // val contextId: String?, // 게임의 세부 ID (예: 스도쿠 퍼즐 Key, 스파이더 난이도, 노노그램 퍼즐 ID)
val playerName: String, // 표준화된 플레이어 이름 필드 // val playerName: String, // 표준화된 플레이어 이름 필드
//
/** * 기본 점수 필드 (정렬 1순위). // /** * 기본 점수 필드 (정렬 1순위).
* - 2048: 점수 (높을수록 좋음) // * - 2048: 점수 (높을수록 좋음)
* - Sudoku: 완료 시간() (낮을수록 좋음) // * - Sudoku: 완료 시간(초) (낮을수록 좋음)
* - Spider: 이동 횟수 (낮을수록 좋음) // * - Spider: 이동 횟수 (낮을수록 좋음)
*/ // */
val primaryScore: Long, // val primaryScore: Long,
//
/** * 보조 점수 필드 (정렬 2순위. : 스파이더의 완료 시간). // /** * 보조 점수 필드 (정렬 2순위. 예: 스파이더의 완료 시간).
*/ // */
val secondaryScore: Long? = null, // val secondaryScore: Long? = null,
//
val timestamp: Instant = Instant.now() // val timestamp: Instant = Instant.now()
) //)
//
/** ///**
* 지원하는 게임 타입을 정의하는 Enum // * 지원하는 게임 타입을 정의하는 Enum
*/ // */
enum class GameType { //enum class GameType {
GAME_2048, // GAME_2048,
SUDOKU, // SUDOKU,
SPIDER, // SPIDER,
NONOGRAM // NONOGRAM
} //}
//
/** ///**
* 랭킹 등록 모든 프론트엔드에서 공통으로 사용할 DTO // * 랭킹 등록 시 모든 프론트엔드에서 공통으로 사용할 DTO
*/ // */
data class UnifiedRankDto( //data class UnifiedRankDto(
val gameType: GameType, // val gameType: GameType,
val contextId: String?, // val contextId: String?,
val playerName: String, // val playerName: String,
val primaryScore: Long, // val primaryScore: Long,
val secondaryScore: Long? = null // val secondaryScore: Long? = null
) //)
//
@Repository //@Repository
interface GameRankRepository : ReactiveSortingRepository<GameRank, String> { //interface GameRankRepository : ReactiveSortingRepository<GameRank, String> {
//
fun save(gameRank: GameRank): Mono<GameRank> // fun save(gameRank: GameRank): Mono<GameRank>
// 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048) // // 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048)
fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc( // fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(
gameType: GameType, // gameType: GameType,
contextId: String? // contextId: String?
): Flux<GameRank> // ): Flux<GameRank>
//
// 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수) // // 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수)
fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc( // fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(
gameType: GameType, // gameType: GameType,
contextId: String? // contextId: String?
): Flux<GameRank> // ): Flux<GameRank>
//
// [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회 // // [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회
fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux<GameRank> // fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux<GameRank>
} //}
//
//
@Service //@Service
class GameRankService( //class GameRankService(
private val rankRepository: GameRankRepository, // private val rankRepository: GameRankRepository,
private val userManager: UserManager ) { // private val userManager: UserManager ) {
/** // /**
* 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다. // * 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다.
*/ // */
fun getRanks(gameType: GameType, contextId: String?): Flux<GameRank> { // fun getRanks(gameType: GameType, contextId: String?): Flux<GameRank> {
return when (gameType) { // return when (gameType) {
// 점수가 높아야 하는 게임 (2048) // // 점수가 높아야 하는 게임 (2048)
GameType.GAME_2048 -> // GameType.GAME_2048 ->
rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(gameType, contextId) // rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(gameType, contextId)
//
// 점수가 낮아야 하는 게임 (스도쿠 시간, 스파이더 무브/시간, 노노그램 시간) // // 점수가 낮아야 하는 게임 (스도쿠 시간, 스파이더 무브/시간, 노노그램 시간)
GameType.SUDOKU, GameType.SPIDER, GameType.NONOGRAM -> // GameType.SUDOKU, GameType.SPIDER, GameType.NONOGRAM ->
rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(gameType, contextId) // rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(gameType, contextId)
} // }
} // }
//
/** // /**
* [수정] 공통 DTO를 받아 랭킹을 저장 (사용자 이름 중복 체크 로직 추가) // * [수정] 공통 DTO를 받아 랭킹을 저장 (사용자 이름 중복 체크 로직 추가)
*/ // */
fun submitRank(rankDto: UnifiedRankDto): Mono<GameRank> { // fun submitRank(rankDto: UnifiedRankDto): Mono<GameRank> {
val auth = SecurityContextHolder.getContext().authentication // val auth = SecurityContextHolder.getContext().authentication
//
// 로그인 사용자인지, 비로그인(익명) 사용자인지 확인 // // 로그인 사용자인지, 비로그인(익명) 사용자인지 확인
val isAuthenticated = auth != null && auth.isAuthenticated && auth !is AnonymousAuthenticationToken // val isAuthenticated = auth != null && auth.isAuthenticated && auth !is AnonymousAuthenticationToken
//
if (isAuthenticated) { // if (isAuthenticated) {
// 로그인 사용자: DTO의 playerName을 실제 로그인한 사용자의 ID로 강제 설정 (보안 강화) // // 로그인 사용자: DTO의 playerName을 실제 로그인한 사용자의 ID로 강제 설정 (보안 강화)
val principal = auth.principal as UserDetails // val principal = auth.principal as UserDetails
val authenticatedUsername = principal.username // val authenticatedUsername = principal.username
//
val gameRank = GameRank( // val gameRank = GameRank(
gameType = rankDto.gameType, // gameType = rankDto.gameType,
contextId = rankDto.contextId, // contextId = rankDto.contextId,
playerName = authenticatedUsername, // 실제 인증된 이름 사용 // playerName = authenticatedUsername, // 실제 인증된 이름 사용
primaryScore = rankDto.primaryScore, // primaryScore = rankDto.primaryScore,
secondaryScore = rankDto.secondaryScore // secondaryScore = rankDto.secondaryScore
) // )
return rankRepository.save(gameRank) // return rankRepository.save(gameRank)
} else { // } else {
// 비로그인 사용자: 입력한 이름이 기존 회원 ID와 중복되는지 확인 // // 비로그인 사용자: 입력한 이름이 기존 회원 ID와 중복되는지 확인
return userManager.findById(rankDto.playerName) // return userManager.findById(rankDto.playerName)
.flatMap<GameRank> { existingUser -> // .flatMap<GameRank> { existingUser ->
// 사용자가 존재하면 에러 발생 // // 사용자가 존재하면 에러 발생
Mono.error(IllegalArgumentException("이미 등록된 회원의 이름입니다. 다른 이름을 사용해주세요.")) // Mono.error(IllegalArgumentException("이미 등록된 회원의 이름입니다. 다른 이름을 사용해주세요."))
} // }
.switchIfEmpty(Mono.defer { // .switchIfEmpty(Mono.defer {
// 사용자가 존재하지 않으면 랭킹 저장 진행 // // 사용자가 존재하지 않으면 랭킹 저장 진행
val gameRank = GameRank( // val gameRank = GameRank(
gameType = rankDto.gameType, // gameType = rankDto.gameType,
contextId = rankDto.contextId, // contextId = rankDto.contextId,
playerName = rankDto.playerName, // playerName = rankDto.playerName,
primaryScore = rankDto.primaryScore, // primaryScore = rankDto.primaryScore,
secondaryScore = rankDto.secondaryScore // secondaryScore = rankDto.secondaryScore
) // )
rankRepository.save(gameRank) // rankRepository.save(gameRank)
}) // })
} // }
} // }
//
/** // /**
* [신규 추가] 특정 플레이어의 모든 게임 랭킹을 조회합니다. // * [신규 추가] 특정 플레이어의 모든 게임 랭킹을 조회합니다.
*/ // */
fun getRanksByPlayer(playerName: String): Flux<GameRank> { // fun getRanksByPlayer(playerName: String): Flux<GameRank> {
return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName) // return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName)
} // }
} //}

View File

@ -72,7 +72,8 @@ interface ImageMetaRepository : ReactiveMongoRepository<ImageMeta, String> {
class ImageMetaService( class ImageMetaService(
private val repository: ImageMetaRepository, private val repository: ImageMetaRepository,
private val logService: LogService, // LogService 주입 private val logService: LogService, // LogService 주입
@Value("\${image.upload.path}") private val uploadPath: String // application.properties의 업로드 경로 주입 @Value("\${image.upload.path}") private val uploadPath: String, // application.properties의 업로드 경로 주입
@Value("\${build.config.run}") private val build_config_run: String
) { ) {
// [신규 추가] 백그라운드 작업용 Coroutine Scope 정의 // [신규 추가] 백그라운드 작업용 Coroutine Scope 정의
@ -95,14 +96,17 @@ class ImageMetaService(
return repository.findRandomImage() return repository.findRandomImage()
} }
// application.properties의 업로드 경로 주입
/** /**
* [신규 추가] Spring Boot가 준비되었을 (부팅 완료) 실행되는 리스너 * [신규 추가] Spring Boot가 준비되었을 (부팅 완료) 실행되는 리스너
*/ */
@Profile("!local") @Profile("!local")
@EventListener(ApplicationReadyEvent::class) @EventListener(ApplicationReadyEvent::class)
fun onApplicationReady() { fun onApplicationReady() {
logService.log("Application ready. Launching initial image DB sync task...") logService.log("Application ${build_config_run} ready. Launching initial image DB sync task...")
launchSyncTask() if (build_config_run.contains("prd")) {
launchSyncTask()
}
} }
/** /**

View File

@ -0,0 +1,70 @@
package kr.lunaticbum.back.lun.model
import org.bson.BsonType
import org.bson.codecs.pojo.annotations.BsonId
import org.bson.codecs.pojo.annotations.BsonRepresentation
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@Document(collection = "Messages")
data class Message(
@Id
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var id: String? = null,
var senderId: String,
var receiverId: String,
var title: String,
var content: String,
var timestamp: Long = System.currentTimeMillis(),
var isRead: Boolean = false
)
@Repository
interface MessageRepository : ReactiveMongoRepository<Message, String> {
fun findByReceiverIdOrderByTimestampDesc(receiverId: String): Flux<Message>
fun countByReceiverIdAndIsRead(receiverId: String, isRead: Boolean): Mono<Long>
fun findBySenderIdOrderByTimestampDesc(senderId: String): Flux<Message>
}
@Service
class MessageService(private val messageRepository: MessageRepository) {
fun getMessagesForUser(userId: String): Flux<Message> {
return messageRepository.findByReceiverIdOrderByTimestampDesc(userId)
}
fun getUnreadMessageCount(userId: String): Mono<Long> {
return messageRepository.countByReceiverIdAndIsRead(userId, false)
}
fun sendMessage(senderId: String, receiverId: String, title: String, content: String): Mono<Message> {
val message = Message(senderId = senderId, receiverId = receiverId, title = title, content = content)
return messageRepository.save(message)
}
fun markMessageAsRead(messageId: String, userId: String): Mono<Message> {
return messageRepository.findById(messageId)
.filter { it.receiverId == userId } // 본인 쪽지만 읽음 처리하도록 보안 강화
.flatMap { message ->
if (!message.isRead) {
message.isRead = true
messageRepository.save(message)
} else {
Mono.just(message)
}
}
}
// [신규] 사용자가 보낸 쪽지를 가져오는 서비스를 추가합니다.
fun getSentMessagesByUser(userId: String): Flux<Message> {
return messageRepository.findBySenderIdOrderByTimestampDesc(userId)
}
}

View File

@ -1,6 +1,7 @@
package kr.lunaticbum.back.lun.model package kr.lunaticbum.back.lun.model
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.google.gson.Gson
import kr.lunaticbum.back.lun.configs.GlobalEnvironment import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.LogService
import lombok.AllArgsConstructor import lombok.AllArgsConstructor
@ -10,17 +11,21 @@ import lombok.NoArgsConstructor
import okio.Timeout import okio.Timeout
import org.bson.BsonType import org.bson.BsonType
import org.bson.codecs.pojo.annotations.BsonId import org.bson.codecs.pojo.annotations.BsonId
import org.bson.codecs.pojo.annotations.BsonIgnore
import org.bson.codecs.pojo.annotations.BsonRepresentation import org.bson.codecs.pojo.annotations.BsonRepresentation
import org.jsoup.Jsoup
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.mongodb.core.FindAndModifyOptions // [추가됨] import org.springframework.data.mongodb.core.FindAndModifyOptions // [추가됨]
import org.springframework.data.mongodb.core.ReactiveMongoTemplate import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.core.query.Criteria import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.data.mongodb.core.query.Query
import org.springframework.data.mongodb.core.query.Update import org.springframework.data.mongodb.core.query.Update
import org.springframework.data.mongodb.repository.Aggregation import org.springframework.data.mongodb.repository.Aggregation
import org.springframework.data.mongodb.repository.ReactiveMongoRepository import org.springframework.data.mongodb.repository.ReactiveMongoRepository
@ -35,10 +40,19 @@ import java.time.Duration
import org.springframework.data.mongodb.core.index.CompoundIndex // [신규 추가] import org.springframework.data.mongodb.core.index.CompoundIndex // [신규 추가]
import org.springframework.data.mongodb.core.index.IndexDirection // [신규 추가] import org.springframework.data.mongodb.core.index.IndexDirection // [신규 추가]
import org.springframework.data.mongodb.core.index.Indexed // [신규 추가] import org.springframework.data.mongodb.core.index.Indexed // [신규 추가]
import org.springframework.data.mongodb.core.query.Query
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.reactive.function.client.WebClient
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.ArrayList
import java.util.Base64 import java.util.Base64
import java.util.Date import java.util.Date
enum class PostType {
STANDARD, // 일반 블로그 글
ABOUT_SITE // 사이트 소개 글
}
@Document(collection = "Post") @Document(collection = "Post")
@CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}") @CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}")
data class Post( data class Post(
@ -74,7 +88,9 @@ data class Post(
var readCount : Long = 0, var readCount : Long = 0,
var voteCount : Long = 0, var voteCount : Long = 0,
var unlikeCount : Long = 0, var unlikeCount : Long = 0,
var isBlocked: Boolean = false var isBlocked: Boolean = false,
// [추가] 게시물 타입을 구분하는 필드. 기본값은 'STANDARD'
var postType: String = PostType.STANDARD.name
) )
@Document(collection = "Comment") @Document(collection = "Comment")
@ -157,7 +173,7 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
fun countByOrderByModifyTimeDesc(): Mono<Long> fun countByOrderByModifyTimeDesc(): Mono<Long>
fun findTop5ByOrderByReadCountDesc(): Flux<Post> fun findTop5ByOrderByReadCountDesc(): Flux<Post>
fun findTop5ByOrderByModifyTimeDesc(): Flux<Post> fun findTop5ByOrderByModifyTimeDesc(): Flux<Post>
fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux<Post>
// [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상) // [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상)
@Aggregation(pipeline = [ @Aggregation(pipeline = [
"{ \$match: { posting: true, isBlocked: false } }", // [수정됨] "{ \$match: { posting: true, isBlocked: false } }", // [수정됨]
@ -264,6 +280,18 @@ class PostManager(
@Autowired @Autowired
private lateinit var bCryptPasswordEncoder: PasswordEncoder private lateinit var bCryptPasswordEncoder: PasswordEncoder
// [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드
fun findLatestAboutPost(): Mono<Post> {
// 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
.next() // Flux에서 첫 번째 아이템(Mono)을 반환
}
// [신규 추가] '사이트 소개' 글의 모든 버전(히스토리)을 찾는 메소드
fun findAboutPostHistory(): Flux<Post> {
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
}
// [신규] 게시물 차단 // [신규] 게시물 차단
fun blockPost(postId: String): Mono<Post> { fun blockPost(postId: String): Mono<Post> {
return postRepository.findById(postId).flatMap { post -> return postRepository.findById(postId).flatMap { post ->
@ -589,3 +617,543 @@ object PayloadDecoder {
return objectMapper.readValue(originalJson, clazz) return objectMapper.readValue(originalJson, clazz)
} }
} }
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "LocationLog")
class LocationLog {
var mFeatureName: String? = null
var mAddressLines: ArrayList<String> = arrayListOf()
var mAdminArea: String? = null
var mSubAdminArea: String? = null
var mLocality: String? = null
var mSubLocality: String? = null
var mThoroughfare: String? = null
var mSubThoroughfare: String? = null
var mPremises: String? = null
var mPostalCode: String? = null
var mCountryCode: String? = null
var mCountryName: String? = null
var mLatitude = 0.0
var mLongitude = 0.0
var mPhone: String? = null
var timeString : String? = null
var mUrl: String? = null
var time : Long = 0L
var userId : String? = null
var bettween : String? = null
val displayTime: String
get() {
// 1. timeString 값이 존재하고 비어있지 않으면, 그 값을 사용한다.
if (!this.timeString.isNullOrBlank()) {
return this.timeString!!
}
// 2. timeString이 없을 경우, 원본 logTime 객체가 있다면 포맷팅해서 반환한다.
if (this.time != null) {
// 원하는 날짜/시간 포맷 정의
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return formatter.format(Date(this.time))
}
// 3. 둘 다 없으면 "시간 없음"을 반환한다.
return "[시간 정보 없음]"
}
override fun toString(): String {
val buffer = StringBuffer()
buffer.append(mFeatureName).append("|").append("\n")
buffer.append(mAddressLines.joinToString(" , ")).append("|").append("\n")
buffer.append(mAdminArea).append("|").append("\n")
buffer.append(mSubAdminArea).append("|").append("\n")
buffer.append(mLocality).append("|").append("\n")
buffer.append(mSubLocality).append("|").append("\n")
buffer.append(mThoroughfare).append("|").append("\n")
buffer.append(mSubThoroughfare).append("|").append("\n")
buffer.append(mPremises).append("|").append("\n")
buffer.append(mPostalCode).append("|").append("\n")
buffer.append(mCountryCode).append("|").append("\n")
buffer.append(mCountryName).append("|").append("\n")
buffer.append(mLatitude).append("|").append("\n")
buffer.append(mLongitude).append("|").append("\n")
buffer.append(mPhone).append("|").append("\n")
buffer.append(mUrl).append("|").append("\n")
return buffer.toString()
}
}
@Repository
interface LocationLogRepository : ReactiveMongoRepository<LocationLog, String> {
@Aggregation(pipeline = [
"{ \$match: { 'time' : { \$gte: ?0 } } }"
])
fun findRecent(since: Long, sort: Sort): Flux<LocationLog>
// @Query("SELECT l FROM LocationLog l WHERE l.timeString >= :since ORDER BY l.timeString DESC")
// fun findRecent(@Param("since") since: String): Flux<LocationLog>
fun findTop30ByOrderByTimeDesc(): Flux<LocationLog>
fun findAllBy() : Mono<LocationLog>
fun findFirstByOrderByTimeDesc() : Mono<LocationLog>
fun findFirstByUserIdOrderByTimeDesc(userId: String) : Mono<LocationLog>
fun save(log: LocationLog): Mono<LocationLog>
}
interface LocationService {
}
@Service
class LocationLogService : LocationService {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var logRepository: LocationLogRepository
fun findAll(pageable: Pageable): Page<LocationLog> {
// 1. 페이지 데이터 가져오기 (비동기 -> 동기 'block()')
// Flux 스트림에 정렬, 스킵, 제한을 적용한 뒤 List로 변환합니다.
val items: List<LocationLog> = logRepository
.findAll(pageable.getSort())
.skip(pageable.getOffset())
.take(pageable.getPageSize().toLong())
.collectList() // Flux<T>를 Mono<List<T>>로 변환
.block() ?: emptyList() // Mono를 block()하여 실제 List<T>를 추출
// 2. 전체 카운트 가져오기 (페이지네이션 계산을 위해 별도 쿼리 필요)
val totalCount: Long = logRepository
.count() // Flux<Long> (count)
.block() ?: 0L // Mono를 block()하여 실제 Long 값을 추출
// 3. Page 구현체(PageImpl)로 조합하여 반환
return PageImpl(items, pageable, totalCount)
}
fun find10() : List<LocationLog> {
val sinceMills = System.currentTimeMillis() - ((24 * 60 * 60 * 1000) * 100)
println("sinceMills >> $sinceMills")
val sort = Sort.by(Sort.Direction.DESC, "time") // 오름차순 정렬
// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
// val since = LocalDateTime.now().minusHours(24).format(formatter)
// println("since >> $since")
val flux = filterByDistanceReactive(logRepository.findRecent(sinceMills,sort), 10.0)
return flux.collectList().block(Duration.ofSeconds(30)) ?: listOf()
}
fun getLocationLog() : LocationLog? {
return logRepository.findFirstByOrderByTimeDesc().block()
}
fun getLocationLogBy(userId : String) : LocationLog? {
return logRepository.findFirstByOrderByTimeDesc().block()
}
fun filterByDistanceReactive(flux: Flux<LocationLog>, minDistanceMeter: Double): Flux<LocationLog> {
return flux
.buffer(2, 1)
.filter { pair ->
if (pair.size < 2) true
else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude) >= minDistanceMeter
}
.map { pair ->
val distance = if (pair.size < 2) 0.0 else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude)
val base = pair[0]
println("base >>> ${base.time} ${base.timeString}")
base.bettween = String.format("%.2f m", distance) // 소수점 두자리까지 거리 표시
base
}
}
// Haversine 거리계산 함수 (단위:m)
fun haversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val R = 6371000.0 // 지구 반지름(m)
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
fun save(log: LocationLog) {
println("saved msg before ${log}")
logRepository.save(log).subscribe( { println("saved msg after ${it}") },{e -> e.printStackTrace()},{
println("saved msg comp")
})
}
}
interface RssDataInterface {
fun title() : String
fun thumbnailUrl() : String
fun originPage() : String
fun description() : String
fun pubDate() : Long
fun category() : RssDataType
fun getCho() : String?
}
enum class RssDataType {
NO_DATA,
YOUTUBE,
NewsFeed,
GURU,
Most,
TAGS,
REDDIT,
REDDIT_nsfw,
Dotax,
FmKorae,
DcInside,
RuliWeb,
Clien,
TheQoo,
Arca;
// fun getResId() = when (this) {
// YOUTUBE -> R.drawable.youtube
// REDDIT, REDDIT_nsfw -> R.drawable.reddit
// Dotax -> R.drawable.daum
// FmKorae -> R.drawable.fmk
// DcInside -> R.drawable.dcinside
// Arca -> R.drawable.arca
// else -> {
// 0
// }
// }
fun defaultImgSize() = when (this) {
YOUTUBE -> 200
REDDIT_nsfw,GURU,Most -> 360
else -> { 120 }
}
// fun getDefaultVisibiliy() = when (this) {
// REDDIT_nsfw,GURU,Most,NewsFeed -> View.GONE
// else -> { View.VISIBLE }
// }
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "RssData")
class RssData : RssDataInterface {
@Id
var originPage : String? = null
var title : String? = null
var description : String? = null
var thumbnail : String? = null
var pubDate : Long = 0L
var category : String? = null
var chosung : String? = null
@BsonIgnore
var mRssDataType : RssDataType? = null
override fun title(): String {
return when(category()){
RssDataType.NewsFeed -> {
if(title?.length ?: 0 > 30) title?.substring(0,30).plus("...") else title ?: ""
}
else -> title ?: ""
}.apply {
// chosung = JamoUtils.split(this).joinToString("")
}
}
override fun thumbnailUrl(): String {
return thumbnail ?: ""
}
override fun originPage(): String {
return originPage ?: ""
}
override fun description(): String {
return when(category()){
RssDataType.YOUTUBE -> {
if(description?.contains("게시자") == true) description!!.split("게시자")[0] else description ?: ""
}
RssDataType.NewsFeed -> {
category().name
}
else -> description.plus(" / ").plus(category().name)
}
}
override fun pubDate(): Long {
return pubDate
}
override fun category(): RssDataType {
if (mRssDataType == null)
mRssDataType = RssDataType.valueOf(category!!)
return mRssDataType!!
}
override fun getCho(): String? {
return chosung
}
}
val USAGT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15"
fun String.getJ() = Jsoup.connect(this).userAgent(USAGT).get()
object FeedParseManager {
val parsers = listOf<SoInterface>(QVZTb2dpcmw,SkFWTW9zdA)
fun parse(doc : org.jsoup.nodes.Document, service: RssDataService) {
try {
parsers.filter { doc.title().contains(it.getName()) }.first()?.let {
it.parse(doc,service)
}
} catch (e : Exception) {
e.printStackTrace()
}
}
}
interface SoInterface{
fun getName() : String
fun parse(doc : org.jsoup.nodes.Document,service: RssDataService)
}
object QVZTb2dpcmw : SoInterface {
override fun getName(): String {
return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray()))
}
override fun parse(doc : org.jsoup.nodes.Document, service : RssDataService) {
var lists = arrayListOf<RssData>()
doc.getElementsByTag("article").forEach { article ->
val title = article.getElementsByTag("a").get(0).attr("title")
val href = article.getElementsByTag("a").get(0).attr("href")
val img = article.getElementsByTag("img").get(0).attr("data-src")
service.save(RssData().apply {
this.originPage = href
this.title = title
this.description = "Sogirl"
this.thumbnail = img
this.pubDate = Date().time
this.category = RssDataType.GURU.name
}) {
// CoroutineScope(Dispatchers.IO).launch {
// service.sendMsg("${title}\n${img}\n${href}")
// }
}
}
// lists.map {
// service.sendMsg("${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}")
// }
}
}
object SkFWTW9zdA : SoInterface {
var dmy = SimpleDateFormat("dd-MM-yyyy")
override fun getName(): String {
return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray()))
}
override fun parse(doc: org.jsoup.nodes.Document, service: RssDataService) {
var lists = arrayListOf<RssData>()
doc.getElementsByClass("card").forEach { card ->
var thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("src") else ""
if (thumb.contains("No+Poster")) thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("data-src") else thumb
var model = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("alt") else ""
if(card.getElementsByClass("card-block").size > 0) if(card.getElementsByClass("card-block").size > 0) {
val link = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("href")
val title = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("title")
val date = card.getElementsByTag("span").get(0).text()
service.save(RssData().apply {
lists.add(this)
description = model
thumbnail = thumb
originPage = link
this.title = title
category = RssDataType.Most.name
try {
pubDate = dmy.parse(date).time
}catch (e : Exception) {e.printStackTrace()}
}){
// CoroutineScope(Dispatchers.IO).launch {
// service.sendMsg("${title}\n${thumb}\n${link}")
// }
}
}
}
// service.sendMsg(lists.map {
// "${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}\n"
// }.joinToString(" \n "))
}
}
@Repository
interface RssDataRepository : ReactiveMongoRepository<RssData, String> {
fun findFirstByOriginPageEquals(originPage : String): Mono<RssData>
fun findAllByOrderByPubDate() : Mono<List<RssData>>
fun save(log: RssData): Mono<RssData>
}
@Service
class RssDataService {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var rssDataRepository: RssDataRepository
fun hasItem(originPage : String) {
}
fun getLocationLog() : List<RssData>? {
return rssDataRepository.findAllByOrderByPubDate().block()
}
fun save(log: RssData, callback : (Boolean)->Unit) {
println("saved msg before ${Gson().toJson(log)}")
log.originPage?.let {
if(rssDataRepository.findFirstByOriginPageEquals(it).block() == null) {
rssDataRepository.save(log)
.subscribe({ println("saved msg after ${it}") }, { e -> e.printStackTrace() }, {
println("saved msg comp")
callback(true)
})
} else {
println("있어???")
}
}
}
@Autowired
lateinit var globalEvv : GlobalEnvironment
suspend fun sendMsg(data : String) {
val client = WebClient.create()
client.get()
.uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${data}")
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
}
}
enum class Visibility {
PUBLIC, // 전체 공개
MEMBERS, // 회원 공개
PRIVATE // 비공개 (나만 보기)
}
enum class MetadataStatus {
PENDING, // 처리 대기 중
COMPLETED, // 처리 완료
FAILED // 처리 실패
}
@Document(collection = "WebBookmark")
data class WebBookmark(
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var id: String? = null,
var userId: String, // 누가 저장했는지
var url: String, // 원본 페이지 URL
var title: String? = null, // 페이지 제목
var description: String? = null, // 페이지 요약 (메타 태그)
var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그)
var userComment: String? = null, // 사용자가 남긴 짧은 의견
var tags: List<String>? = null, // 태그 (예: #kotlin, #spring)
var savedAt: Long = System.currentTimeMillis(), // 저장 시간
// [신규 추가] 공개 범위 필드. 기본값은 PRIVATE.
var visibility: String = Visibility.PRIVATE.name,
// [신규 추가] 좋아요/싫어요 카운트 필드
var voteCount: Long = 0,
var unlikeCount: Long = 0,
var userSelectedImageUrl: String? = null,
var metadataStatus: String = MetadataStatus.PENDING.name
)
@Repository
interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> {
fun findByUserIdOrderBySavedAtDesc(userId: String): Flux<WebBookmark>
fun findByVisibilityInOrderBySavedAtDesc(visibilities: List<String>, pageable: Pageable): Flux<WebBookmark>
fun countByVisibilityIn(visibilities: List<String>): Mono<Long>
fun findByMetadataStatus(status: String): Flux<WebBookmark>
}
@Service
class WebBookmarkService(private val repository: WebBookmarkRepository,
private val reactiveMongoTemplate: ReactiveMongoTemplate
// [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다.
) {
fun getBookmarksForUser(userId: String): Flux<WebBookmark> {
return repository.findByUserIdOrderBySavedAtDesc(userId)
}
fun saveBookmark(bookmark: WebBookmark): Mono<WebBookmark> {
// 여기에 중복 저장 방지 로직 등을 추가할 수 있음
return repository.save(bookmark)
}
// 필요하다면 삭제, 수정 기능 추가
fun deleteBookmark(id: String): Mono<Void> {
return repository.deleteById(id)
}
// [신규 추가] 사용자의 권한에 따라 볼 수 있는 북마크 목록을 페이지네이션으로 조회
fun getVisibleBookmarks(userDetails: UserDetails?, pageable: Pageable): 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)
// Mono.zip을 사용하여 두 비동기 작업(목록 조회, 카운트)을 병렬로 실행
return Mono.zip(bookmarks, totalCount).map { tuple ->
PageImpl(tuple.t1, pageable, tuple.t2)
}
}
/**
* [신규 추가]
* 북마크의 좋아요 카운트를 1 증가시킵니다.
* @param bookmarkId 대상 북마크의 ID
* @return 업데이트된 WebBookmark 객체
*/
fun incrementVote(bookmarkId: String): Mono<WebBookmark> {
val query = Query.query(Criteria.where("id").`is`(bookmarkId))
val update = Update().inc("voteCount", 1)
val options = FindAndModifyOptions.options().returnNew(true)
return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java)
}
/**
* [신규 추가]
* 북마크의 싫어요 카운트를 1 증가시킵니다.
* @param bookmarkId 대상 북마크의 ID
* @return 업데이트된 WebBookmark 객체
*/
fun incrementUnlike(bookmarkId: String): Mono<WebBookmark> {
val query = Query.query(Criteria.where("id").`is`(bookmarkId))
val update = Update().inc("unlikeCount", 1)
val options = FindAndModifyOptions.options().returnNew(true)
return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java)
}
}

View File

@ -26,7 +26,12 @@ import javax.imageio.ImageIO
import kotlin.random.Random import kotlin.random.Random
import org.springframework.data.repository.kotlin.CoroutineCrudRepository import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.data.mongodb.core.index.Indexed import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.repository.reactive.ReactiveSortingRepository
import org.springframework.security.authentication.AnonymousAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import java.io.File import java.io.File
import java.time.Instant
import java.util.UUID import java.util.UUID
@ -501,3 +506,141 @@ interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String>
suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle? suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle? suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
} }
/**
* 모든 게임의 랭킹을 저장하는 통합 모델
*/
@Document(collection = "game_ranks")
data class GameRank(
@Id
val id: String? = null,
val gameType: GameType, // 게임 종류 (2048, SUDOKU, SPIDER 등)
val contextId: String?, // 게임의 세부 ID (예: 스도쿠 퍼즐 Key, 스파이더 난이도, 노노그램 퍼즐 ID)
val playerName: String, // 표준화된 플레이어 이름 필드
/** * 기본 점수 필드 (정렬 1순위).
* - 2048: 점수 (높을수록 좋음)
* - Sudoku: 완료 시간() (낮을수록 좋음)
* - Spider: 이동 횟수 (낮을수록 좋음)
*/
val primaryScore: Long,
/** * 보조 점수 필드 (정렬 2순위. : 스파이더의 완료 시간).
*/
val secondaryScore: Long? = null,
val timestamp: Instant = Instant.now()
)
/**
* 지원하는 게임 타입을 정의하는 Enum
*/
enum class GameType {
GAME_2048,
SUDOKU,
SPIDER,
NONOGRAM
}
/**
* 랭킹 등록 모든 프론트엔드에서 공통으로 사용할 DTO
*/
data class UnifiedRankDto(
val gameType: GameType,
val contextId: String?,
val playerName: String,
val primaryScore: Long,
val secondaryScore: Long? = null
)
@Repository
interface GameRankRepository : ReactiveSortingRepository<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

@ -48,7 +48,7 @@ class From {
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Document(collection = "TelegramMessage") @Document(collection = "TelegramMessage")
class Message { class TlgMessage {
@Id @Id
var message_id: String = "" var message_id: String = ""
@ -89,7 +89,7 @@ class TelegramLocation {
class Result { class Result {
var update_id: Int = 0 var update_id: Int = 0
var message: Message? = null var message: TlgMessage? = null
} }
class TelegramUpdate { class TelegramUpdate {
@ -100,17 +100,17 @@ class TelegramUpdate {
} }
@Repository @Repository
interface TelegramRepository : ReactiveMongoRepository<Message,String> { interface TelegramRepository : ReactiveMongoRepository<TlgMessage,String> {
@Query("{id :?0}") @Query("{id :?0}")
override fun findById(id: String): Mono<Message> override fun findById(id: String): Mono<TlgMessage>
@Query("{id :?0}") @Query("{id :?0}")
fun count(id: Int): Mono<Long> fun count(id: Int): Mono<Long>
fun save(message: Message): Mono<Message> fun save(message: TlgMessage): Mono<TlgMessage>
} }
interface MsgService { interface MsgService {
fun findById(id: String): Mono<Message>? fun findById(id: String): Mono<TlgMessage>?
} }
@Service @Service
@ -123,7 +123,7 @@ class TelegramMsgService : MsgService {
override fun findById(id: String): Mono<Message>? { override fun findById(id: String): Mono<TlgMessage>? {
return telegramRepository.findById(id) return telegramRepository.findById(id)
} }
@ -132,7 +132,7 @@ class TelegramMsgService : MsgService {
return telegramRepository.count(id) return telegramRepository.count(id)
} }
fun save(msg: Message) { fun save(msg: TlgMessage) {
println("saved msg before ${msg}") println("saved msg before ${msg}")
telegramRepository.save(msg).subscribe( { println("saved msg after ${it}") },{e -> e.printStackTrace()},{ telegramRepository.save(msg).subscribe( { println("saved msg after ${it}") },{e -> e.printStackTrace()},{
println("saved msg comp") println("saved msg comp")
@ -166,3 +166,59 @@ class GSRItemPageMap {
var cse_image : ArrayList<Map<String,String>>? = null var cse_image : ArrayList<Map<String,String>>? = null
} }
class Condition {
var text: String? = null
var icon: String? = null
var code: Int = 0
}
class Current {
var last_updated_epoch: Int = 0
var last_updated: String? = null
var temp_c: Double = 0.0
var temp_f: Double = 0.0
var is_day: Int = 0
var condition: Condition? = null
var wind_mph: Double = 0.0
var wind_kph: Double = 0.0
var wind_degree: Int = 0
var wind_dir: String? = null
var pressure_mb: Double = 0.0
var pressure_in: Double = 0.0
var precip_mm: Double = 0.0
var precip_in: Double = 0.0
var humidity: Int = 0
var cloud: Int = 0
var feelslike_c: Double = 0.0
var feelslike_f: Double = 0.0
var windchill_c: Double = 0.0
var windchill_f: Double = 0.0
var heatindex_c: Double = 0.0
var heatindex_f: Double = 0.0
var dewpoint_c: Double = 0.0
var dewpoint_f: Double = 0.0
var vis_km: Double = 0.0
var vis_miles: Double = 0.0
var uv: Double = 0.0
var gust_mph: Double = 0.0
var gust_kph: Double = 0.0
}
class Location {
var name: String? = null
var region: String? = null
var country: String? = null
var lat: Double = 0.0
var lon: Double = 0.0
var tz_id: String? = null
var localtime_epoch: Int = 0
var localtime: String? = null
}
class CurrentWeather {
var location: Location? = null
var current: Current? = null
fun getSummaryInfo(lat : String,lon : String) = "지역:${this.location?.name}\n날씨:${this.current?.condition?.text}\n온도:${this.current?.temp_c}\n습도:${this.current?.humidity}\n" +
"체감온도:${this.current?.feelslike_c}\nhttps://www.accuweather.com/ko/search-locations?query=${lat},${lon}"
}

View File

@ -13,6 +13,7 @@ import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -179,8 +180,13 @@ class UserManager(
override fun loadUserByUsername(username: String?): UserDetails { override fun loadUserByUsername(username: String?): UserDetails {
logService.log("username ${username}") if (username == null) {
var user = findById(username!!)?.blockOptional(Duration.ofMillis(5000L))?.get() ?: User() throw UsernameNotFoundException("Username cannot be null")
}
// 사용자를 찾지 못하면 예외를 던지도록 수정
val user = findById(username)
.blockOptional(Duration.ofMillis(5000L))
.orElseThrow { UsernameNotFoundException("User not found: $username") }
val userRole = user.getRole().name // "READ", "WRITE", 또는 "ADMIN" val userRole = user.getRole().name // "READ", "WRITE", 또는 "ADMIN"

View File

@ -1,57 +0,0 @@
package kr.lunaticbum.back.lun.model
class Condition {
var text: String? = null
var icon: String? = null
var code: Int = 0
}
class Current {
var last_updated_epoch: Int = 0
var last_updated: String? = null
var temp_c: Double = 0.0
var temp_f: Double = 0.0
var is_day: Int = 0
var condition: Condition? = null
var wind_mph: Double = 0.0
var wind_kph: Double = 0.0
var wind_degree: Int = 0
var wind_dir: String? = null
var pressure_mb: Double = 0.0
var pressure_in: Double = 0.0
var precip_mm: Double = 0.0
var precip_in: Double = 0.0
var humidity: Int = 0
var cloud: Int = 0
var feelslike_c: Double = 0.0
var feelslike_f: Double = 0.0
var windchill_c: Double = 0.0
var windchill_f: Double = 0.0
var heatindex_c: Double = 0.0
var heatindex_f: Double = 0.0
var dewpoint_c: Double = 0.0
var dewpoint_f: Double = 0.0
var vis_km: Double = 0.0
var vis_miles: Double = 0.0
var uv: Double = 0.0
var gust_mph: Double = 0.0
var gust_kph: Double = 0.0
}
class Location {
var name: String? = null
var region: String? = null
var country: String? = null
var lat: Double = 0.0
var lon: Double = 0.0
var tz_id: String? = null
var localtime_epoch: Int = 0
var localtime: String? = null
}
class CurrentWeather {
var location: Location? = null
var current: Current? = null
fun getSummaryInfo(lat : String,lon : String) = "지역:${this.location?.name}\n날씨:${this.current?.condition?.text}\n온도:${this.current?.temp_c}\n습도:${this.current?.humidity}\n" +
"체감온도:${this.current?.feelslike_c}\nhttps://www.accuweather.com/ko/search-locations?query=${lat},${lon}"
}

View File

@ -0,0 +1,69 @@
package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.MetadataStatus
import kr.lunaticbum.back.lun.model.WebBookmark
import kr.lunaticbum.back.lun.model.WebBookmarkRepository
import kr.lunaticbum.back.lun.utils.LogService
import org.jsoup.Jsoup
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
@Service
class BookmarkProcessorService(
private val bookmarkRepository: WebBookmarkRepository,
private val logService: LogService
) {
// fixedDelayString = "60000" -> 1분에 한 번씩 실행
@Scheduled(fixedDelayString = "60000")
fun processPendingBookmarks() {
logService.log("Starting scheduled job: Process Pending Bookmarks...")
bookmarkRepository.findByMetadataStatus(MetadataStatus.PENDING.name) // PENDING 상태인 북마크 조회
.flatMap { bookmark ->
// 각 북마크에 대해 메타데이터를 가져오고 DB를 업데이트하는 비동기 작업을 수행
fetchAndApplyMetadata(bookmark)
}
.subscribe(
{ updatedBookmark -> logService.log("Successfully processed bookmark ID: ${updatedBookmark.id}") },
{ error -> logService.log("Error during bookmark processing: ${error.message}") }
)
}
private fun fetchAndApplyMetadata(bookmark: WebBookmark): Mono<WebBookmark> {
return Mono.fromCallable {
// Jsoup 호출은 블로킹(blocking) 작업이므로 fromCallable로 감싸고
// 별도 스레드에서 실행되도록 subscribeOn을 사용
logService.log("Fetching metadata for: ${bookmark.url}")
val doc = Jsoup.connect(bookmark.url).timeout(10000).get() // 10초 타임아웃
// 메타데이터 추출
val title = doc.select("meta[property=og:title]").attr("content").ifEmpty { doc.title() }
val description = doc.select("meta[property=og:description]").attr("content")
val imageUrl = doc.select("meta[property=og:image]").attr("content")
// 북마크 객체 업데이트
bookmark.title = title
bookmark.description = description
bookmark.thumbnailUrl = imageUrl
bookmark.metadataStatus = MetadataStatus.COMPLETED.name // 상태를 COMPLETED로 변경
bookmark
}
.subscribeOn(Schedulers.boundedElastic())
.flatMap { updatedBookmark ->
// 업데이트된 북마크를 DB에 저장
bookmarkRepository.save(updatedBookmark)
}
.onErrorResume { error ->
// 오류 발생 시 상태를 FAILED로 변경하여 저장
logService.log("Failed to fetch metadata for URL: ${bookmark.url}. Error: ${error.message}")
bookmark.metadataStatus = MetadataStatus.FAILED.name
bookmarkRepository.save(bookmark)
}
}
}

View File

@ -1,87 +1,65 @@
// kr/lunaticbum/back/lun/utils/JwtUtil.kt
package kr.lunaticbum.back.lun.utils package kr.lunaticbum.back.lun.utils
import io.jsonwebtoken.* import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.security.Keys import io.jsonwebtoken.security.Keys
import jakarta.servlet.http.Cookie import org.springframework.beans.factory.annotation.Value
import kr.lunaticbum.back.lun.configs.JwtRule import org.springframework.security.core.userdetails.UserDetails
import kr.lunaticbum.back.lun.configs.TokenStatus import org.springframework.stereotype.Component
import lombok.RequiredArgsConstructor
import lombok.extern.slf4j.Slf4j
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.nio.charset.StandardCharsets
import java.security.Key import java.security.Key
import java.util.* import java.util.*
import java.util.function.Function
@Component
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
class JwtUtil { class JwtUtil {
fun getTokenStatus(token: String?, secretKey: Key?): TokenStatus { @Value("\${jwt.secret}")
try { private lateinit var secret: String
var cls = Jwts.parserBuilder()
.setSigningKey(secretKey) @Value("\${jwt.expiration}")
.build() private lateinit var expirationTime: String
.parseClaimsJws(token)
cls.body.keys.forEach { private fun getSigningKey(): Key {
println("${it} >>> ${cls.body.get(it).toString()}") return Keys.hmacShaKeyFor(secret.toByteArray())
}
return TokenStatus.AUTHENTICATED
} catch (e: ExpiredJwtException) {
// log.error(INVALID_EXPIRED_JWT.getMessage())
return TokenStatus.EXPIRED
} catch (e: IllegalArgumentException) {
// log.error(INVALID_EXPIRED_JWT.getMessage())
return TokenStatus.EXPIRED
} catch (e: JwtException) {
throw BusinessException(ErrorCode.INVALID_JWT)
}
} }
fun resolveTokenFromCookie(cookies: Array<Cookie>?, tokenPrefix: JwtRule): String { fun generateToken(userDetails: UserDetails): String {
return Arrays.stream(cookies) val claims = mutableMapOf<String, Any>()
.filter { cookie -> cookie.getName().equals(tokenPrefix.value, ignoreCase = true) } return Jwts.builder()
.findFirst() .setClaims(claims)
.map { it.value } .setSubject(userDetails.username)
.orElse("") .setIssuedAt(Date(System.currentTimeMillis()))
.setExpiration(Date(System.currentTimeMillis() + expirationTime.toLong()))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact()
} }
fun getSigningKey(secretKey: String): Key { fun extractUsername(token: String): String {
val encodedKey = encodeToBase64(secretKey) return extractClaim(token, Claims::getSubject)
return Keys.hmacShaKeyFor(encodedKey.toByteArray(StandardCharsets.UTF_8))
} }
private fun encodeToBase64(secretKey: String): String { fun isTokenValid(token: String, userDetails: UserDetails): Boolean {
return Base64.getEncoder().encodeToString(secretKey.toByteArray()) val username = extractUsername(token)
return (username == userDetails.username && !isTokenExpired(token))
} }
fun resetToken(tokenPrefix: JwtRule): Cookie { private fun isTokenExpired(token: String): Boolean {
val cookie: Cookie = Cookie(tokenPrefix.value, null) return extractExpiration(token).before(Date())
cookie.setMaxAge(0)
cookie.setPath("/")
return cookie
} }
fun extractToken(token: String?, secretKey: Key?): Jws<Claims>? { private fun extractExpiration(token: String): Date {
try { return extractClaim(token, Claims::getExpiration)
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
} catch (e: JwtException) {
throw BusinessException(ErrorCode.INVALID_JWT)
}
} }
}
class BusinessException(error : ErrorCode) : Exception(error.name)
enum class ErrorCode { private fun <T> extractClaim(token: String, claimsResolver: Function<Claims, T>): T {
JWT_TOKEN_NOT_FOUND, val claims = extractAllClaims(token)
NOT_AUTHENTICATED_USER, return claimsResolver.apply(claims)
INVALID_EXPIRED_JWT, }
INVALID_JWT
private fun extractAllClaims(token: String): Claims {
return Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token).body
}
} }

View File

@ -100,4 +100,7 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
server.tomcat.connection-timeout=60s server.tomcat.connection-timeout=60s
# For reactive applications (like yours), also set this timeout # For reactive applications (like yours), also set this timeout
spring.webflux.response-timeout=60s spring.webflux.response-timeout=60s
api.base-url=ss 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

View File

@ -100,4 +100,7 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
server.tomcat.connection-timeout=60s server.tomcat.connection-timeout=60s
# For reactive applications (like yours), also set this timeout # For reactive applications (like yours), also set this timeout
spring.webflux.response-timeout=60s spring.webflux.response-timeout=60s
api.base-url=ss 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

View File

@ -100,4 +100,8 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
server.tomcat.connection-timeout=60s server.tomcat.connection-timeout=60s
# For reactive applications (like yours), also set this timeout # For reactive applications (like yours), also set this timeout
spring.webflux.response-timeout=60s spring.webflux.response-timeout=60s
api.base-url=ss 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

View File

@ -233,6 +233,8 @@ window.addEventListener('DOMContentLoaded', () => {
closePopup(); closePopup();
}); });
} }
checkUnreadMessages(); // 함수 호출 추가
}); });
/* --- (DOMContentLoaded 끝) --- */ /* --- (DOMContentLoaded 끝) --- */
@ -815,6 +817,7 @@ function gotoHome() { document.location.replace(`${getMainPath()}/home.bs`); }
function gotoWrite() { document.location.replace(`${getMainPath()}/blog/edit`); } function gotoWrite() { document.location.replace(`${getMainPath()}/blog/edit`); }
function gotoModify() { document.location.replace(`${getMainPath()}/blog/posts`); } function gotoModify() { document.location.replace(`${getMainPath()}/blog/posts`); }
function gotoWhere() { document.location.replace(`${getMainPath()}/bums/where.bs`); } function gotoWhere() { document.location.replace(`${getMainPath()}/bums/where.bs`); }
function gotoBUMSpace() { document.location.replace(`${getMainPath()}/bums/face.bs`); }
function gotoJoin() { document.location.replace(`${getMainPath()}/user/join.bs`); } function gotoJoin() { document.location.replace(`${getMainPath()}/user/join.bs`); }
// [추가] 네모로직 업로드 페이지로 이동하는 함수 // [추가] 네모로직 업로드 페이지로 이동하는 함수
function gotoPuzzleUpload() { document.location.replace(`${getMainPath()}/puzzle/upload.bs`); } function gotoPuzzleUpload() { document.location.replace(`${getMainPath()}/puzzle/upload.bs`); }
@ -1551,4 +1554,227 @@ async function showConfirm(title, text) {
cancelButtonText: '취소' cancelButtonText: '취소'
}); });
return result.isConfirmed; return result.isConfirmed;
}
function sendTlg(form, type,keyword) {
console.log(form)
let data = {
'name': form.querySelector("#name").value,
'email': form.querySelector("#email").value,
'message': form.querySelector("#message").value,
}
if (data.name != null && data.email != null && data.message != null && data.message.length > 0) {
if(confirm(JSON.stringify(data) + "\n해당 내용으로\n메시지 보내쉴?")) {
post(getMainPath()+"/tlg/repotToMe.bjx",type,JSON.stringify(data),keyword, function (resultData) {
showAlert("서버에 전달됨.")
})
} else {
}
}
return false
}
async function checkUnreadMessages() {
const isLoggedIn = !!document.querySelector('a[href="javascript:logout()"]');
if (!isLoggedIn) return; // 비로그인 상태면 실행 중단
try {
const response = await fetch('/messages/unread-count');
if (response.ok) {
const data = await response.json();
if (data.count > 0) {
const icon = document.getElementById('message-icon');
if (icon) {
icon.style.display = 'inline-block'; // 아이콘 표시
}
}
}
} catch (error) {
console.error('Failed to check for unread messages:', error);
}
}
function handleBookmarkVote(buttonElement, voteType) {
const controls = buttonElement.closest('.vote-controls');
const bookmarkId = controls.dataset.bookmarkId;
controls.querySelectorAll('button').forEach(btn => btn.disabled = true); // 중복 클릭 방지
// [수정] 북마크용 API 엔드포인트 사용
const url = `${getMainPath()}/bookmarks/${bookmarkId}/${voteType === 'like' ? 'like' : 'unlike'}`;
// CSRF 토큰 준비
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const headers = { 'X-CSRF-TOKEN': csrfToken };
fetch(url, { method: 'POST', headers: headers })
.then(res => res.json())
.then(data => {
controls.querySelector('.like-count').innerText = data.voteCount;
controls.querySelector('.unlike-count').innerText = data.unlikeCount;
})
.catch(error => console.error('Error handling bookmark vote:', error))
.finally(() => {
controls.querySelectorAll('button').forEach(btn => btn.disabled = false);
});
}
/**
* 특정 북마크의 댓글 섹션을 열거나 닫습니다.
*/
function toggleCommentSection(bookmarkId) {
const section = document.getElementById(`comment-section-${bookmarkId}`);
if (section.style.display === 'none') {
section.style.display = 'block';
fetchBookmarkComments(bookmarkId); // 처음 열 때 댓글 로드
} else {
section.style.display = 'none';
}
}
/**
* 특정 북마크의 댓글 목록을 불러옵니다.
*/
async function fetchBookmarkComments(bookmarkId) {
const listContainer = document.getElementById(`comments-list-${bookmarkId}`);
listContainer.innerHTML = '댓글 로딩 중...';
const response = await fetch(`${getMainPath()}/bookmarks/${bookmarkId}/comments`);
const data = await response.json();
listContainer.innerHTML = '';
if (data.resultCode === 0 && data.comments.length > 0) {
data.comments.forEach(comment => {
// 기존 블로그 댓글 HTML 생성 함수 재사용
listContainer.innerHTML += createCommentHTML(comment);
});
} else {
listContainer.innerHTML = '아직 댓글이 없습니다.';
}
}
/**
* 북마크에 댓글을 등록합니다.
*/
function submitBookmarkComment(bookmarkId) {
const input = document.getElementById(`comment-input-${bookmarkId}`);
const content = input.value.trim();
if (!content) {
showAlert('알림', '댓글 내용을 입력하세요.');
return;
}
// 블로그 댓글과 동일한 DTO 및 암호화 방식 사용
const commentData = { content: content, parentId: null };
const uploadUrl = `${getMainPath()}/bookmarks/${bookmarkId}/comments`;
// 기존 `post` 유틸리티 함수를 재사용하여 서버에 전송
post(uploadUrl, serverData.enc, JSON.stringify(commentData), serverData.keyword, (resultData) => {
const response = JSON.parse(resultData);
if (response.resultCode === 0) {
input.value = '';
fetchBookmarkComments(bookmarkId); // 댓글 목록 새로고침
} else {
showAlert('오류', '댓글 등록에 실패했습니다: ' + response.resultMsg);
}
});
}
/**
* 북마크 클릭 사용자에게 선택지를 보여주는 함수
* @param {HTMLElement} element - 클릭된 <a> 요소
*/
async function showBookmarkOptions(element) {
const url = element.dataset.url;
const title = element.dataset.title;
const result = await Swal.fire({
title: '어떻게 보시겠어요?',
text: title,
icon: 'question',
showDenyButton: true,
confirmButtonText: '새 탭에서 열기',
denyButtonText: '여기서 보기 (Iframe)',
confirmButtonColor: '#3085d6',
denyButtonColor: '#555',
});
if (result.isConfirmed) {
// '새 탭에서 열기' 선택 시
window.open(url, '_blank');
} else if (result.isDenied) {
// '여기서 보기 (Iframe)' 선택 시
openBookmarkInIframe(url, title);
}
}
/**
* iframe 로드 실패 일관된 처리를 위한 헬퍼 함수
* @param {string} title - 북마크 제목
* @param {string} url - 북마크 URL
*/
function handleIframeLoadFailure(title, url) {
closePopup(); // 팝업 닫기
if (confirm(`'${title}' 페이지를 내부에서 여는 데 실패했습니다.\n\n새 탭에서 여시겠습니까?`)) {
window.open(url, '_blank');
}
}
/**
* 지정된 URL을 Iframe 팝업으로 여는 함수 (try-catch 로직 적용)
* @param {string} url - 표시할 URL
* @param {string} title - 표시할 제목
*/
function openBookmarkInIframe(url, title) {
const popup = document.getElementById('iframe-viewer-popup');
const titleElement = document.getElementById('iframe-viewer-title');
const iframe = document.getElementById('bookmark-iframe');
const overlay = document.querySelector('.dim_layer');
const newTabLink = document.getElementById('iframe-open-new-tab-link');
if (!popup || !titleElement || !iframe || !overlay || !newTabLink) {
console.error('Iframe viewer elements not found!');
return;
}
// iframe의 로딩을 시작하기 전에 src를 초기화하여 이전 상태를 지웁니다.
iframe.src = 'about:blank';
// iframe의 onload 이벤트 핸들러
iframe.onload = () => {
console.log("iframe onload 이벤트 발생. 내부 문서 접근을 시도합니다...");
try {
// 동일 출처 정책(Same-Origin Policy)을 위반하는 접근 시도
// 이 코드가 오류를 발생시키면, 다른 출처의 문서가 로드된 것 (성공 또는 오류 페이지)
const dummyAccess = iframe.contentWindow.location.href;
// 만약 위 코드에서 오류가 발생하지 않았다면, iframe이 동일 출처이거나 비어있다는 의미.
// 외부 사이트 로드는 실패한 것으로 간주합니다.
console.warn("iframe 접근이 차단되지 않았습니다. 로드 실패로 간주합니다.");
handleIframeLoadFailure(title, url);
} catch (e) {
// SecurityError가 발생! 다른 출처의 문서가 성공적으로 로드되었다고 간주합니다.
// (이것이 실제 콘텐츠일 수도, 브라우저의 오류 페이지일 수도 있습니다)
console.log("iframe 접근이 보안 정책에 의해 차단되었습니다. 일단 성공으로 간주합니다.", e);
// 팝업을 그대로 유지
}
};
// 네트워크 오류 등으로 iframe 로드 자체가 실패했을 때를 위한 핸들러
iframe.onerror = () => {
console.error("iframe onerror 이벤트 발생. 로드 실패로 처리합니다.");
handleIframeLoadFailure(title, url);
};
// 제목과 새 탭 링크 설정
titleElement.textContent = title;
newTabLink.href = url;
// 실제 URL로 로딩 시작
iframe.src = url;
// 팝업과 오버레이 표시
overlay.style.display = 'block';
popup.style.display = 'block';
} }

View File

@ -0,0 +1,38 @@
<!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="head">
</th:block>
<th:block layout:fragment="content" id="content">
<section class="wrapper style2">
<div class="container">
<header class="major">
<h2 th:text="${srcPost.title}">소개글 제목</h2>
<p>
최종 수정일: <span th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(srcPost.modifyTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm')}"></span>
</p>
</header>
</div>
</section>
<section class="wrapper style1">
<div class="container">
<article>
<div id="editor"></div>
</article>
</div>
</section>
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script>
// DOM 로드 완료 후 Quill 에디터를 읽기 전용(false)으로 초기화
document.addEventListener('DOMContentLoaded', function() {
initEditor(false);
});
</script>
</th:block>
</html>

View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<head>
<title>Bookmarks</title>
</head>
<th:block layout:fragment="content">
<section class="wrapper style2">
<div class="container">
<header class="major">
<h2>Bookmarks</h2>
<p>다른 사용자들이 저장한 유용한 페이지들을 둘러보세요.</p>
</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="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>
<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>
</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>
</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>
</section>
</th:block>
</html>

View File

@ -7,11 +7,7 @@
> >
<th:block layout:fragment="head"> <th:block layout:fragment="head">
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>
</th:block> </th:block>
<body> <body>
@ -110,6 +106,11 @@
</div> </div>
</div> </div>
</div> </div>
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>
</th:block> </th:block>
</body> </body>
</html> </html>

View File

@ -1,7 +1,10 @@
<!doctype html> <!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml" layout:decorate="~{layout/default_layout}"> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml" layout:decorate="~{layout/default_layout}">
<head> <head>
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
</head> </head>
<body> <body>
<th:block layout:fragment="content" id="content"> <th:block layout:fragment="content" id="content">

View File

@ -0,0 +1,124 @@
<!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="head">
<style>
.message-list { list-style: none; padding-left: 0; }
.message-item { border: 1px solid #ddd; border-radius: 5px; margin-bottom: 1em; }
.message-header { padding: 10px 15px; background: #f7f7f7; cursor: pointer; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; }
.message-item.unread .message-header { font-weight: bold; background: #fffbe5; border-left: 3px solid #FFA500; }
.message-content { padding: 15px; display: none; border-top: 1px solid #ddd; }
.message-content.active { display: block; }
.reply-form { margin-top: 1em; }
.message-title { flex-grow: 1; }
</style>
</th:block>
<th:block layout:fragment="content">
<section class="wrapper style1">
<div class="container">
<header class="major">
<h2 th:text="${pageTitle}"></h2>
</header>
<div class="box">
<ul class="message-list">
<li th:if="${#lists.isEmpty(messages)}">받은 쪽지가 없습니다.</li>
<li th:each="msg : ${messages}" class="message-item" th:classappend="${!msg.isRead} ? 'unread'" th:data-message-id="${msg.id}">
<div class="message-header" onclick="toggleMessage(this)">
<div class="message-title">
<span th:text="${msg.title}"></span>
<small style="margin-left: 1em; color: #777;" th:text="'보낸 사람: ' + ${msg.senderId}"></small>
</div>
<small th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(msg.timestamp).atZone(T(java.time.ZoneId).systemDefault()), 'yyyy-MM-dd HH:mm')}"></small>
</div>
<div class="message-content">
<p style="white-space: pre-wrap;" th:text="${msg.content}"></p>
<hr/>
<div class="reply-form">
<h4>답장 보내기</h4>
<form onsubmit="sendMessage(event, this)">
<input type="hidden" name="receiverId" th:value="${msg.senderId}" />
<div class="row gtr-50">
<div class="col-12">
<input type="text" name="title" placeholder="제목" th:value="'RE: ' + ${msg.title}" required />
</div>
<div class="col-12">
<textarea name="content" placeholder="내용" rows="4" required></textarea>
</div>
<div class="col-12">
<button type="submit" class="button primary">답장 전송</button>
</div>
</div>
</form>
</div>
</div>
</li>
</ul>
</div>
</div>
</section>
<script th:inline="javascript">
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
async function toggleMessage(headerElement) {
const messageItem = headerElement.closest('.message-item');
const content = messageItem.querySelector('.message-content');
const messageId = messageItem.dataset.messageId;
const isOpening = !content.classList.contains('active');
// 모든 열린 쪽지 닫기
document.querySelectorAll('.message-content.active').forEach(c => {
if(c !== content) c.classList.remove('active');
});
content.classList.toggle('active');
if (isOpening && messageItem.classList.contains('unread')) {
const response = await fetch(`/messages/${messageId}/read`, {
method: 'POST',
headers: { [csrfHeader]: csrfToken }
});
if (response.ok) {
messageItem.classList.remove('unread');
// 헤더의 아이콘도 업데이트 할 수 있지만, 페이지 새로고침 전까지는 유지됩니다.
}
}
}
async function sendMessage(event, form) {
event.preventDefault();
const formData = new FormData(form);
const data = {
receiverId: formData.get('receiverId'),
title: formData.get('title'),
content: formData.get('content')
};
const submitButton = form.querySelector('button[type="submit"]');
submitButton.disabled = true;
submitButton.textContent = '전송 중...';
const response = await fetch('/messages/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
body: JSON.stringify(data)
});
if(response.ok) {
alert('답장을 성공적으로 보냈습니다.');
form.querySelector('textarea').value = ''; // 내용만 초기화
toggleMessage(form.closest('.message-item').querySelector('.message-header')); // 답장 후 창 닫기
} else {
alert('답장 보내기에 실패했습니다.');
}
submitButton.disabled = false;
submitButton.textContent = '답장 전송';
}
</script>
</th:block>
</html>

View File

@ -15,6 +15,49 @@
.user-list li, .post-list li { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #eee; } .user-list li, .post-list li { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #eee; }
.user-list li:last-child, .post-list li:last-child { border-bottom: none; } .user-list li:last-child, .post-list li:last-child { border-bottom: none; }
.button.small { margin-left: 0.5em; } .button.small { margin-left: 0.5em; }
.custom-radio {
display: none; /* 기본 라디오 버튼 숨기기 */
}
.custom-label {
position: relative;
padding-left: 25px; /* 라벨 왼쪽에 가짜 버튼을 위한 공간 확보 */
cursor: pointer;
line-height: 20px;
display: inline-block;
color: #555; /* 라벨 텍스트 색상 */
}
/* 가짜 라디오 버튼 (원) 만들기 */
.custom-label::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 18px;
height: 18px;
border: 2px solid #ddd;
border-radius: 50%; /* 원 모양 */
background: #fff;
}
/* 선택되었을 때 가짜 라디오 버튼 스타일 변경 */
.custom-radio:checked + .custom-label::before {
border-color: #FFA500; /* 테두리 색상 변경 (사이트의 포인트 색상) */
background: #FFA500; /* 배경 색상 채우기 */
}
/* 선택되었을 때 원 안에 작은 점 추가 */
.custom-radio:checked + .custom-label::after {
content: '';
position: absolute;
left: 6px;
top: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: white;
}
</style> </style>
</th:block> </th:block>
@ -31,10 +74,15 @@
<div class="tab-link" onclick="openTab(event, 'myPosts')">내가 쓴 글</div> <div class="tab-link" onclick="openTab(event, 'myPosts')">내가 쓴 글</div>
<div class="tab-link" onclick="openTab(event, 'myComments')">내가 쓴 댓글</div> <div class="tab-link" onclick="openTab(event, 'myComments')">내가 쓴 댓글</div>
<div class="tab-link" onclick="openTab(event, 'myRanks')">내 게임 랭킹</div> <div class="tab-link" onclick="openTab(event, 'myRanks')">내 게임 랭킹</div>
<div class="tab-link" onclick="openTab(event, 'myMessages')">쪽지함</div>
<div class="tab-link" onclick="openTab(event, 'myBookmarks')">저장한 페이지</div>
<th:block sec:authorize="hasRole('ADMIN')"> <th:block sec:authorize="hasRole('ADMIN')">
<div class="tab-link" onclick="openTab(event, 'userManagement')">회원 관리</div> <div class="tab-link" onclick="openTab(event, 'userManagement')">회원 관리</div>
<div class="tab-link" onclick="openTab(event, 'postManagement')">게시물 관리</div> <div class="tab-link" onclick="openTab(event, 'postManagement')">게시물 관리</div>
<div class="tab-link" onclick="openTab(event, 'bannerManagement')">배너 관리</div> <div class="tab-link" onclick="openTab(event, 'bannerManagement')">배너 관리</div>
<div class="tab-link" onclick="openTab(event, 'aboutManagement')">사이트 소개 관리</div>
</th:block> </th:block>
</div> </div>
@ -78,7 +126,57 @@
</ul> </ul>
</div> </div>
</div> </div>
<div id="myBookmarks" class="tab-content">
<div class="box">
<h4>새 페이지 저장하기</h4>
<div id="bookmark-form">
<input type="url" id="bookmark-url-input" placeholder="저장할 페이지 URL을 입력하세요" style="margin-bottom: 1em;">
<div id="og-preview" style="display:none; border: 1px solid #ddd; padding: 1em; margin-bottom: 1em; border-radius: 5px;">
<img id="og-image" src="" style="max-width: 150px; float: left; margin-right: 1em;">
<h5 id="og-title"></h5>
<p id="og-description" style="font-size: 0.9em; color: #555;"></p>
</div>
<textarea id="bookmark-comment-input" placeholder="이 페이지에 대한 나의 생각 (선택)" rows="3"></textarea>
<div id="visibility-selector" style="margin-top: 1em; display: flex; align-items: center; flex-wrap: wrap;">
<strong style="margin-right: 1.5em;">공개 범위:</strong>
<div style="display: flex; align-items: center; margin-right: 1.5em;">
<input type="radio" name="visibility" id="visibility-private" value="PRIVATE" class="custom-radio" checked>
<label for="visibility-private" class="custom-label">비공개</label>
</div>
<div style="display: flex; align-items: center; margin-right: 1.5em;">
<input type="radio" name="visibility" id="visibility-members" value="MEMBERS" class="custom-radio">
<label for="visibility-members" class="custom-label">회원 공개</label>
</div>
<div style="display: flex; align-items: center;">
<input type="radio" name="visibility" id="visibility-public" value="PUBLIC" class="custom-radio">
<label for="visibility-public" class="custom-label">전체 공개</label>
</div>
</div>
<button id="save-bookmark-btn" class="button primary" style="margin-top: 1em;">저장하기</button>
</div>
</div>
<div class="box" style="margin-top: 2em;">
<h4>저장된 목록</h4>
<div id="bookmarks-list" class="row">
<div class="col-4 col-12-medium">
<section class="box feature">
<a href="#" class="image featured"><img src="/images/pic01.jpg" alt="" /></a>
<div class="inner">
<header>
<h2>카드 제목</h2>
<p>사용자 코멘트가 여기에 들어갑니다.</p>
</header>
<p style="font-size: 0.8em; color: #888;">원본 페이지 설명...</p>
</div>
</section>
</div>
</div>
</div>
</div>
<div id="myRanks" class="tab-content"> <div id="myRanks" class="tab-content">
<div class="box"> <div class="box">
<ul class="post-list"> <ul class="post-list">
@ -104,7 +202,27 @@
</ul> </ul>
</div> </div>
</div> </div>
<div id="myMessages" class="tab-content">
<div class="box">
<ul class="post-list">
<li th:if="${#lists.isEmpty(myMessages)}">주고받은 쪽지가 없습니다.</li>
<li th:each="msg : ${myMessages}">
<div style="display: flex; align-items: center; gap: 1em;">
<span th:if="${msg.senderId == user.user_id}" class="tag-item" style="background: #e0f7fa;">보냄</span>
<span th:if="${msg.receiverId == user.user_id}" class="tag-item" style="background: #fffbe5;">받음</span>
<div style="min-width: 150px;">
<strong th:if="${msg.senderId == user.user_id}" th:text="'To: ' + ${msg.receiverId}"></strong>
<strong th:if="${msg.receiverId == user.user_id}" th:text="'From: ' + ${msg.senderId}"></strong>
</div>
<a th:href="@{/messages}" th:text="${msg.title}">쪽지 제목</a>
</div>
<span th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(msg.timestamp).atZone(T(java.time.ZoneId).systemDefault()), 'yyyy-MM-dd HH:mm')}"></span>
</li>
</ul>
</div>
</div>
<div id="userManagement" class="tab-content" sec:authorize="hasRole('ADMIN')"> <div id="userManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
<div class="box"> <div class="box">
<h4>권한 요청</h4> <h4>권한 요청</h4>
@ -154,7 +272,8 @@
<ul class="post-list"> <ul class="post-list">
<li th:each="image : ${allImages}" th:id="'image-row-' + ${image.id}"> <li th:each="image : ${allImages}" th:id="'image-row-' + ${image.id}">
<div style="display: flex; align-items: center; gap: 1em;"> <div style="display: flex; align-items: center; gap: 1em;">
<img th:src="@{'/api/images/' + ${image.fileName}}" alt="Image Thumbnail" style="width: 100px; height: 60px; object-fit: cover; border-radius: 4px;"/> <!-- <img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}" alt="Post Thumbnail" />-->
<img th:src="${apiBaseUrl + '/api/images/' + image.fileName + '?type=thumbnail'}" alt="Image Thumbnail" style="width: 100px; height: 60px; object-fit: cover; border-radius: 4px;"/>
<div> <div>
<strong th:text="${image.fileName}"></strong><br> <strong th:text="${image.fileName}"></strong><br>
<span th:if="${image.isBannerCandidate}" style="color: #2a9d8f; font-weight: bold;">(배너로 사용 중)</span> <span th:if="${image.isBannerCandidate}" style="color: #2a9d8f; font-weight: bold;">(배너로 사용 중)</span>
@ -170,6 +289,27 @@
</ul> </ul>
</div> </div>
</div> </div>
<div id="aboutManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
<div class="box">
<h4>사이트 소개글 관리</h4>
<p>
'사이트 소개' 페이지에 표시될 내용입니다. 글을 수정하면 이전 버전은 히스토리로 여기에 남게 됩니다.
</p>
<th:block th:with="latestAbout=${!#lists.isEmpty(aboutPostHistory) ? aboutPostHistory[0] : null}">
<a th:if="${latestAbout != null}" th:href="@{/blog/edit/{postId}(postId=${latestAbout.id})}" class="button primary">최신 소개글 수정</a>
<a th:if="${latestAbout == null}" th:href="@{/blog/edit(type='ABOUT_SITE')}" class="button">새 소개글 작성</a>
</th:block>
<h5 style="margin-top: 2em;">수정 히스토리</h5>
<ul class="post-list">
<li th:each="post : ${aboutPostHistory}">
<a th:href="@{'/blog/viewer/' + ${post.id}}" th:text="${post.title}">수정된 버전 제목</a>
<span th:text="|수정일: ${#dates.format(post.modifyTime, 'yyyy-MM-dd HH:mm')}|"></span>
</li>
<li th:if="${#lists.isEmpty(aboutPostHistory)}">작성된 소개글이 없습니다.</li>
</ul>
</div>
</div>
</div> </div>
</section> </section>
@ -315,6 +455,83 @@
alert('작업 중 오류가 발생했습니다.'); alert('작업 중 오류가 발생했습니다.');
}); });
} }
document.addEventListener('DOMContentLoaded', function() {
const urlInput = document.getElementById('bookmark-url-input');
const preview = document.getElementById('og-preview');
let ogData = {}; // OG 파싱 결과를 저장할 변수
// URL 입력 필드에서 포커스가 벗어났을 때(onblur) OG 정보 파싱 API 호출
urlInput.addEventListener('blur', async function() {
const url = this.value.trim();
if (!url) return;
try {
const response = await fetch(`/api/og/parse?url=${encodeURIComponent(url)}`);
if (!response.ok) throw new Error('파싱 실패');
ogData = await response.json();
// 미리보기 UI 업데이트
document.getElementById('og-title').textContent = ogData.title || '제목 없음';
document.getElementById('og-description').textContent = ogData.description || '';
const ogImage = document.getElementById('og-image');
if (ogData.thumbnailUrl) {
ogImage.src = ogData.thumbnailUrl;
ogImage.style.display = 'block';
} else {
ogImage.style.display = 'none';
}
preview.style.display = 'block';
} catch (error) {
console.error(error);
preview.style.display = 'none';
alert('페이지 정보를 가져오는 데 실패했습니다. URL을 확인해주세요.');
}
});
// 저장 버튼 클릭 이벤트
document.getElementById('save-bookmark-btn').addEventListener('click', async function() {
const comment = document.getElementById('bookmark-comment-input').value.trim();
// [수정] 선택된 공개 범위(visibility) 값을 읽어오는 코드 추가
const visibility = document.querySelector('input[name="visibility"]:checked').value;
const bookmarkData = {
url: urlInput.value.trim(),
// title, description 등은 ogData에서 가져오는 것은 그대로 유지
title: ogData.title,
description: ogData.description,
thumbnailUrl: ogData.thumbnailUrl,
userComment: comment,
// [수정] bookmarkData 객체에 visibility 프로퍼티 추가
visibility: visibility
};
if (!bookmarkData.url) {
alert('URL을 입력해주세요.');
return;
}
// 서버에 북마크 저장 요청 (이하 코드는 동일)
const response = await fetch('/user/bookmarks/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[csrfHeader]: csrfToken
},
body: JSON.stringify(bookmarkData)
});
if (response.ok) {
alert('페이지가 저장되었습니다.');
location.reload();
} else {
alert('저장에 실패했습니다.');
}
});
});
</script> </script>
</th:block> </th:block>
</html> </html>

View File

@ -5,14 +5,7 @@
layout:decorate="~{layout/default_layout}"> layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="head"> <th:block layout:fragment="head">
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {
initEditor(false)
fetchComments(serverData.id);
});</script>
</th:block> </th:block>
<th:block layout:fragment="content" id="content"> <th:block layout:fragment="content" id="content">
@ -108,5 +101,14 @@
</div> </div>
</div> </div>
</section> </section>
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {
initEditor(false)
fetchComments(serverData.id);
});</script>
</th:block> </th:block>
</html> </html>

View File

@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"> <html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">>
<th:block th:fragment="footer"> <th:block th:fragment="footer">
<script th:inline="javascript"> <script th:inline="javascript">
/*<![CDATA[*/ /*<![CDATA[*/
@ -9,60 +10,66 @@
/*]]>*/ /*]]>*/
</script> </script>
<div id="footer"> <div id="footer">
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<section class="col-3 col-6-narrower col-12-mobilep"> <section class="col-3 col-6-narrower col-12-mobilep">
<h3 id="ranking-title">Rank of Views</h3> <h3 id="ranking-title">Rank of Views</h3>
<ul class="rank_of_view" > <ul class="rank_of_view" >
</ul> </ul>
</section> </section>
<section class="col-3 col-6-narrower col-12-mobilep"> <section class="col-3 col-6-narrower col-12-mobilep">
<h3>Recent of Posts</h3> <h3>Recent of Posts</h3>
<ul class="recent_posts"> <ul class="recent_posts">
</ul> </ul>
</section> </section>
<section class="col-6 col-12-narrower"> <section class="col-6 col-12-narrower">
<h3>SEND TO ME(TELEGRAM BOT)</h3> <h3>SEND TO ME(TELEGRAM BOT)</h3>
<div id="tlg_form" > <div id="tlg_form" >
<div class="row gtr-50"> <div class="row gtr-50">
<div class="col-6 col-12-mobilep"> <div class="col-6 col-12-mobilep">
<div sec:authorize="isAuthenticated()">
<input type="text" name="name" id="name" placeholder="Name" th:value="${#authentication.principal.username}" readonly />
</div>
<div sec:authorize="isAnonymous()">
<input type="text" name="name" id="name" placeholder="Name" /> <input type="text" name="name" id="name" placeholder="Name" />
</div> </div>
<div class="col-6 col-12-mobilep"> </div>
<input type="email" name="email" id="email" placeholder="Email" />
</div> <div class="col-6 col-12-mobilep">
<div class="col-12"> <input type="email" name="email" id="email" placeholder="Email" />
<textarea name="message" id="message" placeholder="Message" rows="5"></textarea> </div>
</div> <div class="col-12">
<div class="col-12"> <textarea name="message" id="message" placeholder="Message" rows="5"></textarea>
<ul class="actions"> </div>
<li><input type="submit" class="button alt" value="Send Message" onclick="callSendTlg()" /></li> <div class="col-12">
</ul> <ul class="actions">
</div> <li><input type="submit" class="button alt" value="Send Message" onclick="callSendTlg()" /></li>
</ul>
</div> </div>
</div> </div>
</section> </div>
</div> </section>
</div>
<!-- Icons -->
<ul class="icons">
<li><a href="#" class="icon brands fa-twitter"><span class="label">Twitter</span></a></li>
<li><a href="#" class="icon brands fa-facebook-f"><span class="label">Facebook</span></a></li>
<li><a href="#" class="icon brands fa-github"><span class="label">GitHub</span></a></li>
<li><a href="#" class="icon brands fa-linkedin-in"><span class="label">LinkedIn</span></a></li>
<li><a href="#" class="icon brands fa-google-plus-g"><span class="label">Google+</span></a></li>
</ul>
<!-- Copyright -->
<div class="copyright">
<ul class="menu">
<li>&copy;lunaticbum All rights reserved</li><li>Origin Design from:<a href="http://html5up.net">HTML5 UP</a></li>
</ul>
</div> </div>
</div> </div>
<!-- Icons -->
<ul class="icons">
<li><a href="#" class="icon brands fa-twitter"><span class="label">Twitter</span></a></li>
<li><a href="#" class="icon brands fa-facebook-f"><span class="label">Facebook</span></a></li>
<li><a href="#" class="icon brands fa-github"><span class="label">GitHub</span></a></li>
<li><a href="#" class="icon brands fa-linkedin-in"><span class="label">LinkedIn</span></a></li>
<li><a href="#" class="icon brands fa-google-plus-g"><span class="label">Google+</span></a></li>
</ul>
<!-- Copyright -->
<div class="copyright">
<ul class="menu">
<li>&copy;lunaticbum All rights reserved</li><li>Origin Design from:<a href="http://html5up.net">HTML5 UP</a></li>
</ul>
</div>
</div>
<script type="text/javascript"> <script type="text/javascript">

View File

@ -15,19 +15,21 @@
<ul> <ul>
<li id="menu_home" ><a th:href="@{/}">Home</a></li> <li id="menu_home" ><a th:href="@{/}">Home</a></li>
<li id="menu_posts"><a href="blog/posts">Posts</a></li> <li id="menu_posts"><a href="blog/posts">Posts</a></li>
<li id="menu_nonogram"><a href="puzzle/play">Nonogram</a></li> <li id="menu_bookmarks"><a href="/bookmarks">Bookmarks</a></li>
<li id="menu_2048"><a href="puzzle/2048">2048</a></li> <li id="menu_drop">
<li id="menu_sudoku"><a href="puzzle/sudoku">sudoku</a></li> <a href="#">Game</a>
<li id="menu_spider"><a href="puzzle/spider">spider</a></li> <ul>
<!-- <li id="menu_sec"><a href="left-sidebar">Left Sidebar</a></li>--> <li id="menu_nonogram"><a href="puzzle/play">Nonogram</a></li>
<!-- <li id="menu_thr"><a href="right-sidebar">Right Sidebar</a></li>--> <li id="menu_2048"><a href="puzzle/2048">2048</a></li>
<!-- <li id="menu_four"><a href="two-sidebar">Two Sidebar</a></li>--> <li id="menu_sudoku"><a href="puzzle/sudoku">sudoku</a></li>
<li id="menu_spider"><a href="puzzle/spider">spider</a></li>
</ul>
</li>
<li id="menu_drop"> <li id="menu_drop">
<a href="#">About</a> <a href="#">About</a>
<ul> <ul>
<li><a href="javascript:gotoWhere()">bums's where</a></li> <li><a href="javascript:gotoWhere()">bums's where</a></li>
<li><a href="#">Magna phasellus</a></li> <li><a href="javascript:gotoBUMSpace()">BUM'sPase</a></li>
<li><a href="#">Etiam sed tempus</a></li>
<li> <li>
<a href="#">Submenu</a> <a href="#">Submenu</a>
<ul> <ul>
@ -46,9 +48,12 @@
</ul> </ul>
</li> </li>
<th:block sec:authorize="isAuthenticated()"> <li sec:authorize="isAuthenticated()">
<li><a th:href="@{/user/info}">내 정보</a></li> <a href="/user/info" th:text="${#authentication.principal.username}">사용자ID</a>
</th:block> <a href="/messages" id="message-icon" style="display: none; color: #FFA500; margin-left: 5px;" title="새 쪽지">
쪽지함<i class="icon solid fa-envelope"></i>
</a>
</li>
<th:block sec:authorize="!isAuthenticated()"> <th:block sec:authorize="!isAuthenticated()">
<li id="menu_login"> <li id="menu_login">
<a class="open-login-popup" to="#loginPopup">Login</a> <a class="open-login-popup" to="#loginPopup">Login</a>

View File

@ -73,7 +73,23 @@
</div> </div>
</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;">
<h4 id="iframe-viewer-title" style="margin: 0; font-size: 1em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></h4>
<a href="#" class="btn_layerClose" style="font-size: 1.5em;" onclick="closePopup()">×</a>
</div>
<div class="pop_conts" style="flex-grow: 1; padding: 0;">
<iframe id="bookmark-iframe" src="" style="width: 100%; height: 100%; border: none;">
이 브라우저는 iframe을 지원하지 않습니다.
</iframe>
</div>
<div class="pop_footer" style="padding: 10px 20px; border-top: 1px solid #eee; background: #f8f8f8; text-align: center; font-size: 0.9em;">
콘텐츠가 표시되지 않나요?
<a id="iframe-open-new-tab-link" href="#" target="_blank" class="button small alt" style="margin-left: 1em; vertical-align: middle;" onclick="closePopup()">새 탭에서 열기</a>
</div>
</div>
</div>
<div id="unified-game-success-modal" class="pop_layer"> <div id="unified-game-success-modal" class="pop_layer">
<div class="pop_container"> <div class="pop_conts"> <h2 id="ugsm-title">🎉 성공! 🎉</h2> <div class="pop_container"> <div class="pop_conts"> <h2 id="ugsm-title">🎉 성공! 🎉</h2>
<p id="ugsm-message">여기에 성공 메시지가 표시됩니다.</p> <p id="ugsm-message">여기에 성공 메시지가 표시됩니다.</p>