From 1ab12cb6d9e562663ec4c18f5ee5e1bdf86c407d Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Mon, 15 Sep 2025 17:18:44 +0900 Subject: [PATCH] ... --- build.gradle.kts | 93 +- .../back/lun/configs/SecurityConfig.kt | 90 +- .../back/lun/controllers/BlogController.kt | 1188 +++++------ .../lunaticbum/back/lun/controllers/Home.kt | 524 ++--- .../back/lun/controllers/UserController.kt | 92 +- .../kr/lunaticbum/back/lun/model/Post.kt | 306 ++- .../lunaticbum/back/lun/model/RequestModel.kt | 36 - .../kr/lunaticbum/back/lun/model/User.kt | 68 +- src/main/resources/static/css/common.css | 551 ----- src/main/resources/static/css/main.css | 660 +++++- src/main/resources/static/css/private.css | 128 -- src/main/resources/static/js/common.js | 70 +- src/main/resources/static/js/main.js | 67 - src/main/resources/static/js/sha256.js | 1034 ++++----- src/main/resources/static/js/template.js | 411 ++++ src/main/resources/static/js/test.js | 1 - src/main/resources/static/js/tj.js | 2 - src/main/resources/static/js/util.js | 342 --- .../templates/content/{blog => }/editor.html | 0 .../resources/templates/content/licenses.html | 1866 +++++++++++++++-- .../templates/content/{blog => }/posts.html | 17 +- .../templates/content/private/where.html | 1 - .../templates/content/user/my_info.html | 232 ++ .../templates/content/{blog => }/viewer.html | 9 +- .../resources/templates/fragments/footer.html | 18 +- .../resources/templates/fragments/header.html | 10 +- .../templates/fragments/includes.html | 21 +- .../resources/templates/fragments/title.html | 12 +- .../templates/layout/default_layout.html | 14 +- 29 files changed, 4951 insertions(+), 2912 deletions(-) delete mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/model/RequestModel.kt delete mode 100644 src/main/resources/static/css/common.css delete mode 100644 src/main/resources/static/css/private.css delete mode 100644 src/main/resources/static/js/main.js create mode 100644 src/main/resources/static/js/template.js delete mode 100644 src/main/resources/static/js/test.js delete mode 100644 src/main/resources/static/js/tj.js delete mode 100644 src/main/resources/static/js/util.js rename src/main/resources/templates/content/{blog => }/editor.html (100%) rename src/main/resources/templates/content/{blog => }/posts.html (81%) create mode 100644 src/main/resources/templates/content/user/my_info.html rename src/main/resources/templates/content/{blog => }/viewer.html (91%) diff --git a/build.gradle.kts b/build.gradle.kts index f8745db..acafac1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,26 @@ + import com.github.jk1.license.render.* import com.github.jk1.license.filter.ExcludeTransitiveDependenciesFilter import com.github.jk1.license.filter.LicenseBundleNormalizer +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import com.github.jk1.license.render.InventoryMarkdownReportRenderer +import org.jsoup.Jsoup + +//import org.gradle.internal.impldep.org.jsoup.Jsoup + + +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath ("org.jsoup:jsoup:1.18.1") + // 빌드 스크립트에서 commonmark 라이브러리를 사용할 수 있도록 추가합니다. + classpath("org.commonmark:commonmark:0.18.0") + } +} + plugins { kotlin("jvm") version "1.9.25" @@ -8,6 +28,7 @@ plugins { id("org.springframework.boot") version "3.3.4" id("io.spring.dependency-management") version "1.1.6" id("com.github.jk1.dependency-license-report") version "2.0" + } group = "kr.lunaticbum.back" @@ -135,20 +156,72 @@ tasks.jar { }) } +// ✅ licenseReport는 이전과 동일하게 Markdown을 생성하도록 둡니다. licenseReport { - // 라이센스 고지 파일을 반환할 경로 default는 $projectDir/reports/dependency-license + outputDir = "$projectDir/build/licenses" + renderers = arrayOf(InventoryMarkdownReportRenderer()) +// filters = arrayOf(com.github.jk1.license.filter.LicenseBundleNormalizer(), com.github.jk1.license.filter.ExcludeTransitiveDependenciesFilter()) +} - // markdown 생성 -// renderers = listOf(InventoryMarkdownReportRenderer()).toTypedArray() +tasks.register("updateLicensePage") { + dependsOn("generateLicenseReport") - // html 생성 - renderers = listOf(InventoryHtmlReportRenderer()).toTypedArray() + doLast { +// file("$projectDir/build/licenses").listFiles().forEach { +// println("${it.absolutePath}: ${it.name}") +// } + // ... 로그 출력 로직은 그대로 유지 ... + println("🚀 'updateLicensePage' 태스크를 시작합니다.") - // xml 생성 - // renderers = [new XmlReportRenderer()] + val licenseMarkdownFile = file("$projectDir/build/licenses/licenses.md") + val targetHtmlFile = file("src/main/resources/templates/content/licenses.html") - // 보고서에 첫 번째 수준 종속성만 표기 - filters = listOf(LicenseBundleNormalizer(), ExcludeTransitiveDependenciesFilter()).toTypedArray() -} \ No newline at end of file + println(" - 원본 마크다운 파일: ${licenseMarkdownFile.path}") + println(" - 대상 HTML 파일: ${targetHtmlFile.path}") + + if (!licenseMarkdownFile.exists()) { + throw GradleException("❌ 라이선스 마크다운 파일이 생성되지 않았습니다. '${licenseMarkdownFile.path}'") + } + + val licenseMarkdown = licenseMarkdownFile.readText() + println(" - 마크다운 파일을 성공적으로 읽었습니다. (내용 길이: ${licenseMarkdown.length})") + + val parser = Parser.builder().build() + val renderer = HtmlRenderer.builder().build() + val licenseHtml = renderer.render(parser.parse(licenseMarkdown)) + println(" - 마크다운을 HTML로 변환했습니다. (HTML 길이: ${licenseHtml.length})") + + + // ✅ Jsoup으로 HTML 파일을 파싱합니다. + val doc = Jsoup.parse(targetHtmlFile, "UTF-8") + + // ✅ CSS 선택자를 이용해 ID가 'license-content-container'인 태그를 선택하고 + // 그 내부 HTML을 생성된 라이선스 내용으로 교체합니다. + doc.selectFirst("#license-content-container")?.html(licenseHtml) + + println(" - HTML 파일 내 placeholder div의 내용을 교체했습니다.") + + // ✅ 변경된 HTML 내용을 파일에 다시 씁니다. + targetHtmlFile.writeText(doc.outerHtml()) + + println("✅ 라이선스 정보(HTML)가 '${targetHtmlFile.name}' 파일에 성공적으로 업데이트되었습니다.") + } +} + +// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결 +// [수정 전] tasks.build { dependsOn(tasks.getByName("updateLicensePage")) } +tasks.named("build") { // [수정 후] 'build' 태스크를 더 안전하게 참조합니다. + dependsOn(tasks.named("updateLicensePage")) +} + +tasks.named("bootJar") { // [수정 후] 'build' 태스크를 더 안전하게 참조합니다. + dependsOn(tasks.named("updateLicensePage")) +} + +// +//// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결 +//tasks.build { +// dependsOn(tasks.getByName("updateLicensePage")) +//} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt index f7bb44c..a8e94a5 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt @@ -15,28 +15,27 @@ import org.springframework.http.MediaType import org.springframework.security.access.AccessDeniedException import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer -import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.core.AuthenticationException import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.web.AuthenticationEntryPoint import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.access.AccessDeniedHandler import org.springframework.security.web.authentication.RememberMeServices -import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository import org.springframework.web.ErrorResponse import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfigurationSource import org.springframework.web.cors.UrlBasedCorsConfigurationSource -import javax.sql.DataSource @Configuration @EnableWebSecurity +@EnableMethodSecurity // @PreAuthorize 어노테이션을 사용하기 위해 추가 class SecurityConfig( private val userManager: UserManager, private val bCryptPasswordEncoder: BCryptPasswordEncoder, @@ -47,95 +46,89 @@ class SecurityConfig( @Bean fun webSecurityCustomizer(): WebSecurityCustomizer { - // 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다. (권한 검사 불필요) + // 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다. return WebSecurityCustomizer { web -> - web.ignoring().requestMatchers("/blog/post/images/**") + web.ignoring().requestMatchers("/api/images/**", "/images/**") } } - // RememberMeServices를 Bean으로 생성하고 필드에 할당하거나, 생성자 주입을 할 수 있음 @Bean fun rememberMeServices(): RememberMeServices { val key = "your-remember-me-key" return PersistentTokenBasedRememberMeServices(key, userManager, tokenRepository as PersistentTokenRepository? ).apply { - setParameter("remember-me") // 기본 파라미터명 - setTokenValiditySeconds(86400) // 토큰 유효시간 설정 - // 필요시 setAlwaysRemember(true) 등 추가 설정 가능 - println("CALLED rememberMeServices") + setParameter("remember-me") + setTokenValiditySeconds(86400 * 7) // 7일 } } @Bean fun corsConfigurationSource(): CorsConfigurationSource { val configuration = CorsConfiguration() - configuration.allowedOrigins = listOf("*") // 모든 도메인 허용 - configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") // 모든 HTTP 메서드 허용 - configuration.allowedHeaders = listOf("*") // 모든 헤더 허용 - + configuration.allowedOrigins = listOf("*") + configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + configuration.allowedHeaders = listOf("*") val source = UrlBasedCorsConfigurationSource() - source.registerCorsConfiguration("/**", configuration) // 모든 경로에 이 설정 적용 + source.registerCorsConfiguration("/**", configuration) return source } @Bean fun filterChain(http: HttpSecurity): SecurityFilterChain { - // ★★★ 1. CORS 설정을 적용하도록 .cors {} 를 추가합니다. ★★★ http.cors { } .csrf { csrf -> - // [수정] 사용자의 요구사항대로 공개 POST API 목록을 CSRF 예외에 다시 추가합니다. csrf.ignoringRequestMatchers( "/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx", - "/blog/post/imageUpload.bjx", "/blog/post.bjx", - "/puzzle/**", // ★ 게임 관련 API (전체) - "/puzzle/play/**", // ★ - "/bums/save/**", // ★ 위치 저장 API - "/rank/**", // ★ 랭킹 API - "/sudoku/**" // ★ + "/api/ranks/submit" // 통합 랭킹 API ) }.authorizeHttpRequests { auth -> auth - // 1. 정적 리소스 = permitAll (변경 없음) + // 1. 정적 리소스 = permitAll .requestMatchers( - "/webfonts/**", "/css/**", "/js/**", "/images/**", - "/webjars/**", "/assets/**" + "/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**" ).permitAll() // 2. 공개 GET API 및 페이지 = permitAll .requestMatchers(HttpMethod.GET, "/", "/home.bs", "/bums/where.bs", - "/user/login.bs", "/user/signup.bs", - "/blog/viewer/**", "/blog/posts", "/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx", - "/blog/comments/{commentId}/replies.bjx", - "/blog/posts/{postId}/comments.bjx", - "/puzzle/play", "/puzzle/2048", "/puzzle/sudoku", "/puzzle/spider", - "/puzzle/**", // ★ 게임 GET 요청 전체 허용 - "/rank/**", // ★ 랭킹 GET 요청 전체 허용 - "/sudoku/**" // ★ + "/user/login.bs", "/user/join.bs", + "/blog/viewer/**", "/blog/posts", + "/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx", + "/blog/posts/{postId}/comments.bjx", "/blog/comments/{commentId}/replies.bjx", + "/blog/categories.bjx", "/blog/hashtags.bjx", + "/puzzle/**", "/api/ranks/list", "/licenses" ).permitAll() - // 3. 공개 POST API = permitAll (요구사항 반영) + // 3. 공개 POST API = permitAll .requestMatchers(HttpMethod.POST, - "/user/login.bjx", - "/user/joinUser.bjx", - "/tlg/repotToMe.bjx", - "/bums/save/loc.api", // ★ 위치 저장 POST 공개 - "/puzzle/spider/**", // ★ 스파이더 POST API(deal, undo 등) 전체 공개 - "/rank/**", // ★ 랭킹 제출 POST API 전체 공개 - "/sudoku/**" // ★ 스도쿠 POST API 전체 공개 + "/user/login.bjx", "/user/joinUser.bjx", + "/api/ranks/submit", + // [수정] 와일드카드를 사용하여 모든 게시물의 좋아요/싫어요 허용 + "/blog/post/*/like.bjx", + "/blog/post/*/unlike.bjx" ).permitAll() - // [중요] 블로그 '좋아요', '댓글 작성' 등은 위 permitAll 목록에 없으므로 - // 여전히 '4. 나머지 요청'으로 분류되어 인증 + CSRF 보호를 받습니다. + // 4. 'WRITE' 또는 'ADMIN' 권한이 필요한 요청 + .requestMatchers( + "/blog/edit/**", + "/blog/post.bjx", + "/blog/post/imageUpload.bjx" + ).hasAnyRole("WRITE", "ADMIN") - // 4. 나머지 모든 요청 = authenticated (변경 없음) + // 5. 'ADMIN' 권한이 필요한 요청 (my_info.html의 관리자 기능) + .requestMatchers( + "/user/approve-writer/**", "/user/reject-writer/**", + "/blog/post/*/block", "/blog/post/*/unblock" + ).hasRole("ADMIN") + + // 6. 나머지 모든 요청 = authenticated (인증 필요) .anyRequest().authenticated() }.formLogin { form -> - // (이하 formLogin, rememberMe, logout 설정은 동일) - form.loginPage("/user/login.bs") + form.loginPage("/home.bs?action=login") + .loginProcessingUrl("/user/login.bs") // 로그인 처리 URL 명시 (선택사항) .defaultSuccessUrl("/", true) .permitAll() }.rememberMe { rememberMe -> @@ -156,9 +149,10 @@ class SecurityConfig( authenticationManagerBuilder .userDetailsService(userManager) .passwordEncoder(bCryptPasswordEncoder) - return authenticationManagerBuilder.build() // .and() 없이 직접 build() 호출 + return authenticationManagerBuilder.build() } + private val unauthorizedEntryPoint = AuthenticationEntryPoint { request: HttpServletRequest?, response: HttpServletResponse, authException: AuthenticationException? -> val fail: ErrorResponse = ErrorResponse.create( Throwable("아직 못들어와"), diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt index f25dead..75483e8 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt @@ -1,777 +1,611 @@ package kr.lunaticbum.back.lun.controllers -import com.drew.imaging.ImageMetadataReader -import com.drew.metadata.Metadata +import com.fasterxml.jackson.databind.ObjectMapper import com.google.gson.Gson -import com.google.maps.GeoApiContext -import com.google.maps.GeocodingApi -import com.google.maps.model.LatLng -import jakarta.servlet.http.HttpServletRequest +import com.google.gson.JsonParser import jakarta.servlet.http.HttpServletResponse -import kotlinx.coroutines.reactive.awaitSingleOrNull +import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull -import kr.lunaticbum.back.lun.configs.GlobalEnvironment -import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey -import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11 -import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey import kr.lunaticbum.back.lun.model.* import kr.lunaticbum.back.lun.utils.LogService -import kr.lunaticbum.back.lun.utils.getFileExtension +import kr.lunaticbum.back.lun.utils.plainText import net.coobird.thumbnailator.Thumbnails -import org.commonmark.node.Node -import org.commonmark.parser.Parser -import org.commonmark.renderer.html.HtmlRenderer import org.jsoup.Jsoup import org.jsoup.nodes.Element -import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value -import org.springframework.core.io.Resource -import org.springframework.core.io.UrlResource import org.springframework.data.domain.PageImpl -import org.springframework.data.domain.Pageable -import org.springframework.data.web.PageableDefault +import org.springframework.data.domain.PageRequest +import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.http.ResponseEntity -import org.springframework.security.core.Authentication +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.UserDetails +import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile -import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Flux import reactor.core.publisher.Mono -import java.io.* +import java.io.File +import java.io.IOException import java.net.URLDecoder -import java.security.Principal +import java.net.URLEncoder +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption import java.text.SimpleDateFormat import java.util.* -import kr.lunaticbum.back.lun.model.ImageMeta // [신규 추가] -import kr.lunaticbum.back.lun.model.ImageMetaService // [신규 추가] -import javax.imageio.ImageIO // [신규 추가] -@RestController -@RequestMapping("/blog") -class BlogController(private val commentService : CommentService) { - companion object { - val TEMPTOKEN = "TEMP_TOKEN_VIBUM" - } +// --- API 응답을 위한 DTO (Data Transfer Object) 클래스들 --- - @Autowired - private lateinit var imageMetaService: ImageMetaService +data class PostListResponse(val posts: List) +data class CommentResponse(val resultCode: Int, val resultMsg: String, val comments: List? = null) +data class PostSaveResponse(val resultCode: Int, val resultMsg: String, val data: PostIdData? = null) +data class PostIdData(val postId: String) +data class VoteResponse(val voteCount: Long, val unlikeCount: Long) +data class ImageUploadResponse(val resultCode: Int, val resultMsg: String, val fileName: String? = null) +data class TagResponse(val resultCode: Int = 0, val resultMsg: String = "OK", val tags: List) - @Autowired - lateinit var globalEvv : GlobalEnvironment - @Autowired - private lateinit var locationLogService: LocationLogService +/** + * 블로그의 모든 웹 요청을 처리하는 통합 메인 컨트롤러입니다. + * (기존 Home.kt + BlogController.kt + 누락되었던 모든 기능 포함) + */ +@Controller +@RequestMapping("/") // 모든 주요 요청을 처리하기 위해 최상위 경로로 매핑 +class BlogController( + // 생성자를 통해 모든 서비스 의존성을 주입받습니다 (Spring 권장 방식). + private val postManager: PostManager, + private val imageMetaService: ImageMetaService, + private val logService: LogService, + private val commentService: CommentService, + private val objectMapper: ObjectMapper // JSON 직렬화/역직렬화를 위해 추가 +) { - @Autowired - private lateinit var postManager: PostManager + // --- Helper Properties & Data Classes --- - @Autowired - lateinit var logService: LogService - val WRITE_PERMISSION_KEY = "PERMISSION" -// @GetMapping("write/{token}","write.bs") -// fun writ(@PathVariable token : String? ) : ResultMV{ -// val vm = ResultMV("content/blog/write") -// if (token.equals(TEMPTOKEN)) { -// vm.modelMap.put(WRITE_PERMISSION_KEY,"OK") -// vm.modelMap.put(EncTypeKey, EncType11) -// vm.modelMap.put(ApiKeyWordKey,"WRITE") -// vm.modelMap.put("title","회원이 들어는 구나~!!") -// vm.modelMap.put("defaultTitle","무제(無題) (Untitled, ${SimpleDateFormat("yyyy-MM-dd HH:mm").format(Date())})") -// } else { -// vm.modelMap.put(WRITE_PERMISSION_KEY,"NO") -// } -// return vm -// } + @Value("\${image.upload.path}") + private val uploadPath: String? = null - @PostMapping("post.bjx") - fun post(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity { - logService.log(httpServletRequest.requestURI) - logService.log(jsonString) - var postId = "" - var lResultCode = 0 - var lResultMsg = "Suscces" - val decodedBytes: ByteArray = Base64.getDecoder().decode(jsonString) - String(decodedBytes).let { - Gson().fromJson(it, RequestModel::class.java)?.let { model -> - logService.log(Gson().toJson(model)) - model.data?.let { jsonString -> - try { - val reqString = jsonString.split(GlobalEnvironment.padding(model.getKeyword())) - val nb = arrayListOf() - val na = arrayListOf() - reqString[0].replace(GlobalEnvironment.padding(model.getKeyword()),"").split("").toList().let { na.addAll(it) } - reqString[1].replace(GlobalEnvironment.padding(model.getKeyword()),"").split("").toList().let { nb.addAll(it) } - var max = nb.size + na.size - var fullData = arrayListOf() - for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } } + private data class DeltaOp(val insert: Any) + private data class Delta(val ops: List) - // 1. 클라이언트 JSON을 가져옵니다. (콘솔에서 정상 확인됨) - val jsonFromClient = fullData.joinToString("") - logService.log(fullData.joinToString("")) - // 2. JSON을 객체로 "단 한 번만" 파싱합니다. - var target = Gson().fromJson(jsonFromClient, Post::class.java) ?: Post() + // --- Private Helper Methods --- - if (target.writeTime < 1L) { - // === A. 신규 게시물 저장 로직 === - target.id = null // 새 문서이므로 ID는 null - target.writeTime = System.currentTimeMillis() + /** + * Post 객체를 받아 뷰에 표시하기 좋게 가공하는 헬퍼 메서드입니다. + * [수정됨] 이미지 URL을 새로운 API 경로인 /api/images/ 로 생성합니다. + */ + private fun processPostForView(post: Post): Post { + // 1. URL 디코딩: 인코딩되어 저장된 모든 텍스트 필드를 디코딩합니다. + // ?.let {} 구문을 사용하여 null-safe하게 처리합니다. + post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: "" + post.content = post.content?.let { URLDecoder.decode(it, "UTF-8") } ?: "" + post.tags = post.tags?.let { URLDecoder.decode(it, "UTF-8") } ?: "" + post.category = post.category?.let { URLDecoder.decode(it, "UTF-8") } ?: "none" + post.firstAddress = post.firstAddress?.let { URLDecoder.decode(it, "UTF-8") } ?: "" + post.modifyAddress = post.modifyAddress?.let { URLDecoder.decode(it, "UTF-8") } ?: "" - // [버그 수정] 새 글 저장 시 modifyTime을 writeTime과 동일하게 설정 - target.modifyTime = target.writeTime - - // [신규 추가] 새 글 저장 시, 현재 인증된 사용자를 'writer'로 설정 - try { - val authentication = SecurityContextHolder.getContext().authentication - val username = (authentication.principal as? UserDetails)?.username ?: authentication.name - logService.log(username) - target.writer = username - } catch (e: Exception) { - target.writer = "Anonymous" // 인증 정보 가져오기 실패 시 - } - - // 3. 새 마스터 게시물을 저장하고, 저장된 객체(ID 포함)를 받아옵니다. - val savedPost = postManager.save(target).block() // 동기식으로 저장 완료 대기 - - if (savedPost?.id != null) { - lResultCode = 0 - lResultMsg = "New post saved" - // 4. 새로 생성된 마스터 게시물의 ID를 반환합니다. - val responce = ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply { - this.resultCode = lResultCode - this.resultMsg = lResultMsg - this.data.put("postId" , savedPost.id!!) - }) - return responce - } else { - lResultMsg = "Failed to save new post" - lResultCode = 7200 - } - - } else { - // === B. 기존 게시물 수정 (새 버전 생성) 로직 === - // (글쓴이는 client-sent 'target' 객체에 이미 포함되어 있으므로 별도 설정 필요 없음) - if (target.writer.isNullOrEmpty()) { - try { - val authentication = SecurityContextHolder.getContext().authentication - val username = (authentication.principal as? UserDetails)?.username ?: authentication.name - logService.log(username) - target.writer = username - } catch (e: Exception) { - target.writer = "Anonymous" // 인증 정보 가져오기 실패 시 - } - } - // 3. (정상 로직) 이 객체에 "수정 시간"을 설정합니다. - target.modifyTime = System.currentTimeMillis() - - // 4. 이 게시물이 "버전 기록용 사본"임을 설정합니다. - if (target.originId == null) { - target.originId = target.id - } - target.id = null // Mongo가 새 ID를 생성하도록 ID를 null로 변경 - - // 5. 모든 데이터(새 카테고리, 태그, modifyTime 포함)가 담긴 "새 버전 문서"를 저장합니다. - val savedVersionPost = postManager.save(target).block() // 동기식으로 저장 완료 대기 - - if (savedVersionPost?.id != null) { - lResultCode = 0 - lResultMsg = "New post version saved" - // 6. 새로 생성된 "버전 문서"의 ID를 클라이언트에게 반환합니다. - val responce = ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply { - this.resultCode = lResultCode - this.resultMsg = lResultMsg - this.data.put("postId",savedVersionPost.id!!) - }) - return responce - } else { - lResultMsg = "Failed to save post version" - lResultCode = 7300 - } - } - - } catch (e: Exception) { - e.printStackTrace() - lResultMsg = "unknown exception" - lResultCode = 7999 - } - } - } + // 2. 기본값 설정: 제목이 비어있을 경우, 작성 시간을 기반으로 기본 제목을 생성합니다. + if (post.title.isNullOrBlank()) { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm") + post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]" } - val responce = ResponseEntity.ok().headers { - }.contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply { - this.resultCode = lResultCode - this.resultMsg = lResultMsg - this.data.put("postId",postId) - }) - return responce - } + var firstImgSrc: String? = null + val defaultThumb = "/images/pic01.jpg" // 기본 썸네IL 경로를 상수로 정의 - @GetMapping("rankOfViews.bjx") - fun rankOfViews(httpServletRequest: HttpServletRequest): Mono> { - logService.log(httpServletRequest.requestURI) - val resultCode = 0 - val resultMsg = "Success" - - return postManager.getTop10Posts() - .collectList() // Flux -> Mono> - .map { postsList -> - val postsResult = PostsResult().apply { - this.resultCode = resultCode - this.resultMsg = resultMsg - this.posts = postsList // List 할당 가능 - } - ResponseEntity.ok() - .contentType(MediaType.APPLICATION_JSON) - .body(postsResult) - } - } - - - @GetMapping("recentOfPost.bjx") - fun recentOfPost(httpServletRequest: HttpServletRequest): Mono> { - logService.log(httpServletRequest.requestURI) - val resultCode = 0 - val resultMsg = "Success" - - return postManager.getRecent10Posts() - .collectList() // Flux -> Mono> - .map { postsList -> - val postsResult = PostsResult().apply { - this.resultCode = resultCode - this.resultMsg = resultMsg - this.posts = postsList // List 할당 가능 - } - ResponseEntity.ok() - .contentType(MediaType.APPLICATION_JSON) - .body(postsResult) - } - } - - @GetMapping("viewer/{postId}") - fun viewer(@PathVariable postId : String) : ResultMV{ - val vm = ResultMV("content/blog/viewer") - postManager.getPost(postId).block().apply { - this?.title = URLDecoder.decode(this?.title) - println("this?.content >>> ${this?.content}") - if (this?.content is String){ - this?.content = URLDecoder.decode(this?.content) - } else { - this?.content = Gson().toJson(this?.content) - } - this?.category = URLDecoder.decode(this?.category?:"", "UTF-8") - this?.tags = URLDecoder.decode(this?.tags ?:"", "UTF-8") - - globalEvv.gapiKey?.let { - if (this?.firstAddress?.length ?: 0 < 4){ - try { - var addrs = GeocodingApi.reverseGeocode(GeoApiContext.Builder().apiKey(it).build(), LatLng(this?.firstPostLat!!,this?.firstPostLon!!)).await() - this.firstAddress = addrs.first().formattedAddress - postManager.save(this) - } catch (e: Exception) {} - } - if (this?.modifyAddress?.length ?: 0 < 4){ - try { - var addrs = GeocodingApi.reverseGeocode(GeoApiContext.Builder().apiKey(it).build(), LatLng(this?.modifyLat!!,this?.modifyLon!!)).await() - this.modifyAddress = addrs.first().formattedAddress - postManager.save(this) - } catch (e: Exception) {} - } - } - vm.modelMap.put("srcPost",this) + try { + // 3. 콘텐츠 처리 (Delta or HTML) + // Delta(JSON) 형식인지 먼저 시도 + JsonParser.parseString(post.content) + val (text, firstImg) = extractFromDelta(post.content!!) + post.html = text // Jsoup.parse() 대신 Delta에서 추출한 순수 텍스트를 사용 + firstImgSrc = firstImg + } catch (e: Exception) { + // JSON 파싱 실패 시 일반 HTML로 간주 + val doc = Jsoup.parse(post.content) + post.html = doc.text() // HTML 태그를 제외한 순수 텍스트만 요약으로 저장 + firstImgSrc = doc.select("img").first()?.attr("src") } - return vm - } + // 4. 이미지 및 썸네일 경로 설정 (로직 중앙화) + if (!firstImgSrc.isNullOrBlank()) { + val filename = firstImgSrc.substringAfterLast("/") + // 원본 이미지 URL 경로 설정 + post.image = "/api/images/$filename" -// @GetMapping("modify.bs") -// fun modify(httpServletRequest: HttpServletRequest, @RequestParam("token") token : String?) : ResultMV{ -// logService.log("incoming modify") -// val vm = ResultMV("content/blog/modify") -// val authentication = SecurityContextHolder.getContext().authentication -// val principal = authentication.principal -// if (principal is UserDetails) { -// val username = principal.username -// // 추가 정보 사용 가능 -// postManager.find20()?.apply { -// forEach { -// it.title = URLDecoder.decode(it.title) -// val content = URLDecoder.decode(it.content) -// it.content = if (content.length > 50) content.substring(0,150) else content -// } -// vm.modelMap.put("chunkedPosts", this.chunked(3)) -// } -// vm.modelMap.put(WRITE_PERMISSION_KEY,"OK") -// vm.modelMap.put("path","editor/") -// vm.modelMap.put("SK",token) -// } -// vm.modelMap.put("rowKey","chunkedPosts_") -// return vm -// } - -// @GetMapping("editor/{postId}") -// fun editor(@PathVariable postId : String) : ResultMV{ -// val vm = ResultMV("content/blog/editor") -// postManager.getPost(postId).block().apply { -// this?.title = URLDecoder.decode(this?.title) -// this?.content = URLDecoder.decode(this?.content) -// vm.modelMap.put("srcPost",this) -// } -// return vm -// } - - @GetMapping(value = ["/edit", "/edit/{postId}"]) - fun editPost(@PathVariable(required = false) postId: String?): ResultMV { - // 뷰는 'editor' 하나만 사용합니다. - val vm = ResultMV("content/blog/editor") - - if (postId == null) { - // 새 글 작성 (postId가 없는 경우) - // 새 Post 객체를 모델에 추가하여 th:object에서 오류가 나지 않도록 합니다. - val newPost = Post().apply { - title = "무제(無題) (${SimpleDateFormat("yyyy-MM-dd HH:mm").format(Date())})" - content = "" // 내용은 비워둡니다. - } - vm.modelMap["srcPost"] = newPost - vm.modelMap["pageTitle"] = "새 글 작성" // 페이지 제목을 동적으로 설정 + // 썸네일 생성 및 경로 설정 + generateThumbnail(filename, 200) // 너비 200px 썸네일 생성 + val thumbFilename = filename.substringBeforeLast(".") + "_thumbnail." + filename.substringAfterLast(".") + post.thumb = "/api/images/$thumbFilename" } else { - // 기존 글 수정 (postId가 있는 경우) - postManager.getPost(postId).block()?.apply { - this.title = URLDecoder.decode(this.title) - this.content = URLDecoder.decode(this.content) - - this.category = URLDecoder.decode(this.category, "UTF-8") - this.tags = URLDecoder.decode(this.tags, "UTF-8") - - vm.modelMap["srcPost"] = this - vm.modelMap["pageTitle"] = "글 수정" // 페이지 제목을 동적으로 설정 - } + // 게시물에 이미지가 없는 경우, 기본 썸네일을 지정합니다. + post.image = null + post.thumb = defaultThumb } - // 글쓰기 권한 및 기타 필요한 데이터를 모델에 추가합니다. - vm.modelMap.put(WRITE_PERMISSION_KEY,"OK") - vm.modelMap.put(EncTypeKey, EncType11) - vm.modelMap.put(ApiKeyWordKey,"WRITE") + return post + } - return vm + // =================================================================== + // [신규 추가] 이미지 제공 API + // =================================================================== + /** + * 지정된 파일 이름의 이미지를 파일 시스템에서 읽어 HTTP 응답으로 반환합니다. + * @param filename 요청할 이미지의 파일명 (예: 1234-abcd.jpg) + * @return 이미지 데이터가 포함된 ResponseEntity 객체 + */ + @GetMapping("/api/images/{filename:.+}") + @ResponseBody + fun getImage(@PathVariable filename: String): ResponseEntity { + if (uploadPath.isNullOrBlank()) { + return ResponseEntity.notFound().build() + } + + try { + val imagePath: Path = Paths.get(uploadPath, filename) + // 보안: 요청된 파일이 실제 업로드 경로 내에 있는지 확인 (Path Traversal 공격 방지) + if (!imagePath.normalize().startsWith(Paths.get(uploadPath).normalize())) { + return ResponseEntity.badRequest().build() + } + + if (Files.exists(imagePath) && Files.isReadable(imagePath)) { + val imageBytes = Files.readAllBytes(imagePath) + + // 파일 확장자를 기반으로 적절한 Content-Type 헤더 설정 + val contentType = when (filename.substringAfterLast('.').lowercase()) { + "jpg", "jpeg" -> MediaType.IMAGE_JPEG + "png" -> MediaType.IMAGE_PNG + "gif" -> MediaType.IMAGE_GIF + else -> MediaType.APPLICATION_OCTET_STREAM + } + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"$filename\"") + .contentType(contentType) + .body(imageBytes) + } + } catch (e: IOException) { + logService.log("Error reading image file: $filename, Error: ${e.message}") + // 파일을 읽는 중 오류가 발생하면 500 서버 에러를 반환할 수 있습니다. + return ResponseEntity.internalServerError().build() + } + + // 파일이 존재하지 않으면 404 Not Found 응답 + return ResponseEntity.notFound().build() } /** - * [수정] /posts 엔드포인트 로직 - * @PageableDefault(size = 8) 추가: 기본 페이지 크기를 8로 설정 + * Quill Editor의 Delta JSON 형식 문자열에서 순수 텍스트와 첫 번째 이미지 URL을 추출합니다. */ - @GetMapping("posts") - suspend fun posts(@PageableDefault(size = 8) pageable: Pageable, authentication: Authentication?) : ResultMV { // @PageableDefault 추가 - val vm = ResultMV("content/blog/posts") - try { - val postsList: List - val totalPosts: Long + private fun extractFromDelta(deltaJson: String): Pair { + val delta: Delta = Gson().fromJson(deltaJson, Delta::class.java) + val textOnly = StringBuilder() + var firstImage: String? = null - - // [수정] 사용자의 권한 중 'ROLE_ADMIN'이 있는지 확인합니다. - val isAdmin = authentication?.authorities?.any { it.authority == "ROLE_ADMIN" } ?: false - - if (isAdmin) { - // [관리자]: 모든 버전의 글을 조회합니다. - logService.log("User is ADMIN. Loading all post versions.") - // 2. Use awaitSingleOrNull() instead of blocking assignment - postsList = postManager.findAllVersionsPaginated(pageable).awaitSingleOrNull() ?: emptyList() - totalPosts = postManager.countAllVersions().awaitSingleOrNull() ?: 0L // 3. Use awaitSingleOrNull() - } else { - // [모든 방문자 (비로그인 + 일반로그인)]: 고유한 최신 버전의 글만 조회합니다. - logService.log("User is ANONYMOUS or NON-ADMIN. Loading unique latest posts.") - // 4. Use awaitSingleOrNull() - THIS FIXES THE TYPE MISMATCH ERROR - postsList = postManager.findLatestUniquePaginated(pageable).awaitSingleOrNull() ?: emptyList() - totalPosts = postManager.countLatestUnique().awaitSingleOrNull() ?: 0L // 5. Use awaitSingleOrNull() - } -// if (principal != null) { -// // [인증 사용자]: 모든 버전의 글을 조회합니다. -// postsList = postManager.findAllVersionsPaginated(pageable) // 이름 변경된 메서드 호출 -// totalPosts = postManager.countAllVersions().block() ?: 0L -// } else { -// // [익명 사용자]: 고유한 최신 버전의 글만 조회합니다. -// postsList = postManager.findLatestUniquePaginated(pageable) // 신규 메서드 호출 -// totalPosts = postManager.countLatestUnique().block() ?: 0L -// } - - // 조회된 목록에 대해 Jsoup 파싱 등 후처리 (기존 로직과 동일) - postsList.forEach { - println("it.id ==> ${it.id}") - it.title = URLDecoder.decode(it.title) - it.content = URLDecoder.decode(it.content) - val parser: Parser = Parser.builder().build() - val document: Node = parser.parse(it.content) - val renderer = HtmlRenderer.builder().build() - Jsoup.parse(renderer.render(document))?.let { doc -> - val firstImg: Element? = doc.select("img")?.first() - val imgSrc: String = firstImg?.attr("src") ?: "" - it.image = imgSrc - it.thumb = imgSrc.replace(imgSrc.split("/").last(), imgSrc.split("/").last().replace(".","_thumbnail.")) - generateThumbnail(imgSrc.split("/").last(), 200) - it.html = doc.text() + delta.ops.forEach { op -> + if (op.insert is String) { + textOnly.append(op.insert) + } else if (op.insert is Map<*, *> && firstImage == null) { + val obj = op.insert as Map<*, *> + if (obj["image"] != null) { + firstImage = obj["image"].toString() } - it.title = if ((it.title?.length ?: 0) >= 1) it.title else "" } - - // [수정] 결과를 List 대신 PageImpl 객체로 생성합니다. - val postsPage = PageImpl(postsList, pageable, totalPosts) - - // [수정] 모델에 'Posts'(List) 대신 'postsPage'(Page)를 담습니다. - vm.modelMap.put("postsPage", postsPage) - - } catch (ex: Exception) { - ex.printStackTrace() - // [수정] 에러 발생 시에도 빈 Page 객체를 전달합니다. - vm.modelMap.put("postsPage", PageImpl(emptyList(), pageable, 0)) } - - return vm + return textOnly.toString() to firstImage } - fun generateThumbnail(originalPath: String, targetWidth: Int) { + /** + * 원본 이미지 파일로부터 지정된 너비의 썸네일을 생성합니다. + */ + private fun generateThumbnail(originalFilename: String, targetWidth: Int) { + if (uploadPath.isNullOrBlank() || originalFilename.isBlank()) return try { - val originalFile = File("$uploadPath${File.separator}$originalPath") - println("origin ${originalPath}") - println("thumb ${originalPath - .replace(".", "_thumbnail.")}") - // 썸네일 경로 생성 (예: /upload/uuid.jpg → /upload/uuid_thumbnail.jpg) - val thumbnailPath = originalPath - .replace(".", "_thumbnail.") + val originalFile = File(uploadPath, originalFilename) + val thumbnailFilename = originalFilename.substringBeforeLast(".") + "_thumbnail." + originalFilename.substringAfterLast(".") + val thumbnailFile = File(uploadPath, thumbnailFilename) - - val thumbnailFile = File("$uploadPath${File.separator}$thumbnailPath") - // 썸네일 이미 존재하면 종료 - if (thumbnailFile.exists()) { - println("썸네일 이미 존재: $thumbnailPath") + if (thumbnailFile.exists() || !originalFile.exists()) { return } - - // 원본 파일 존재 확인 - if (!originalFile.exists()) { - println("원본 파일 없음: $originalPath") - return - } - - // 썸네일 생성 (가로 기준 비율 유지) Thumbnails.of(originalFile) .width(targetWidth) .keepAspectRatio(true) .toFile(thumbnailFile) - println("썸네일 생성 완료: $thumbnailPath") } catch (e: IOException) { - println("썸네일 생성 실패: ${e.message}") + logService.log("Thumbnail generation failed for $originalFilename: ${e.message}") } } - @GetMapping("recent") - fun recent() : ResultMV{ - val vm = ResultMV("content/blog/viewer") - locationLogService.find10().forEach { - logService.log(Gson().toJson(it)) - } - locationLogService.getLocationLog()?.let { - try { - val client0 = WebClient.create() - val result = client0.get() - .uri("http://api.weatherapi.com/v1/current.json?key=${globalEvv.weatherApiKey}&q=${it.mLatitude},${it.mLongitude}&aqi=no") - .retrieve() - .bodyToMono(String::class.java) - .block() ?: "FAIL" - Gson().fromJson(result, CurrentWeather::class.java)?.let { sss -> - logService.log("지역:${sss.location?.name}\n날씨:${sss.current?.condition?.text}\n온도:${sss.current?.temp_c}\n습도:${sss.current?.humidity}\n" + - "체감온도:${sss.current?.feelslike_c}\nhttps://www.accuweather.com/ko/search-locations?query=${it.mLatitude},${it.mLongitude}") - } - } - catch (e : Exception) { + // =================================================================== + // 1. 페이지 렌더링 (GET, 브라우저에 HTML 페이지를 보여주는 역할) + // =================================================================== + /** + * 웹사이트의 메인 페이지 (홈)를 렌더링합니다. + */ + @GetMapping("/", "/home.bs") + @ResponseBody + suspend fun home(): ResultMV { + val vm = ResultMV("content/home") + try { + val randomImage: ImageMeta? = imageMetaService.getRandomImage().awaitSingleOrNull() + if (randomImage != null) { + vm.modelMap["randomBannerImage"] = randomImage.path } + + val postsList: List = postManager.find8().awaitSingleOrNull() ?: emptyList() + vm.modelMap["Posts"] = postsList.map { processPostForView(it) } + vm.modelMap["path"] = "/blog/viewer/" + + } catch (ex: Exception) { + ex.printStackTrace() + logService.log("Error loading home page: ${ex.message}") } return vm } - @GetMapping("categories.bjx") - fun getCategories(): Mono> { - val resultCode = 0 - val resultMsg = "Success" - // Replace with your actual database query for categories - val categories = listOf("Technology", "Travel", "Food", "Lifestyle") - return Mono.just(ResponseEntity.ok().body(TagResult().apply { - this.resultCode = resultCode - this.resultMsg = resultMsg - this.tags = categories - })) - } + /** + * [수정됨] 게시물 목록 페이지를 역할 기반으로 렌더링합니다. + */ + @GetMapping("/blog/posts") + suspend fun postsList( + @RequestParam(value = "page", defaultValue = "0") page: Int, + @AuthenticationPrincipal userDetails: UserDetails? // 현재 로그인한 사용자 정보 주입 + ): ResultMV { + val vm = ResultMV("content/posts") + val pageable = PageRequest.of(page, 8) - @GetMapping("hashtags.bjx") - fun getHashtags(): Mono> { - val resultCode = 0 - val resultMsg = "Success" - // Replace with your actual database query for hashtags - val hashtags = listOf("kotlin", "spring", "travelgram", "foodie", "blogging") - return Mono.just(ResponseEntity.ok().body(TagResult().apply { - this.resultCode = resultCode - this.resultMsg = resultMsg - this.tags = hashtags - })) - } + val roles = userDetails?.authorities?.map { it.authority } ?: emptyList() + val username = userDetails?.username - @Value("\${image.upload.path}") - private val uploadPath: String? = null + val posts: List + val total: Long - @Value("\${resource.handler}") - private val resourceHandler: String? = null - - - @ResponseBody - @GetMapping("post/images/{fileName}") - fun getImage(@PathVariable fileName : String) : Resource { - val imgUploadPath = ("file:" +uploadPath + File.separator + fileName) - return UrlResource.from(imgUploadPath) - } - - @PostMapping("post/imageUpload.bjx") - fun postImage(@RequestPart("file") upload: MultipartFile, res: HttpServletResponse, req: HttpServletRequest): ResponseEntity { - var lResultCode = 0 - var lResultMsg = "Success" - var out: FileOutputStream? = null - var targetFile: File? = null - - val uuid = UUID.randomUUID() - val extension: String = getFileExtension(upload.originalFilename) ?: "" - - try { - val bytes = upload.bytes - - val f = File(uploadPath) - if (!f.exists()) f.mkdirs() - - // 원본 이미지 저장 경로 - val originalImagePath = "$uploadPath${File.separator}$uuid.$extension" - logService.log("Original image path: $originalImagePath") - - // 썸네일 저장 경로 - val thumbnailPath = "$uploadPath${File.separator}${uuid}_thumbnail.$extension" - logService.log("Thumbnail path: $thumbnailPath") - - targetFile = File(originalImagePath) - if (!targetFile.parentFile.exists()) targetFile.parentFile.mkdirs() - - // 원본 이미지 저장 - out = FileOutputStream(originalImagePath) - out.write(bytes) - out.flush() - - // --- [신규 로직 시작] --- - try { - // 1. 저장된 원본 파일에서 이미지 크기(dimensions) 추출 - val savedFile = File(originalImagePath) - val bufferedImage = ImageIO.read(savedFile) - val imgWidth = bufferedImage.width - val imgHeight = bufferedImage.height - - // 2. ImageMeta 객체 생성 - val metadata = ImageMeta( - fileName = "$uuid.$extension", - originalFileName = upload.originalFilename, - fileType = upload.contentType, - fileSize = upload.size, - width = imgWidth, - height = imgHeight, - uploadTime = System.currentTimeMillis(), - path = "/blog/post/images/$uuid.$extension" // 이미지를 불러오는 GetMapping 경로 기준 - ) - - // 3. 메타데이터를 DB에 저장 (Reactive 호출을 동기식으로 대기) - imageMetaService.save(metadata).block() // .block()은 실제 프로덕션에서는 권장되지 않으나, 현재 컨트롤러 구조(동기식 반환)에 맞춤 - - } catch (metaError: Exception) { - // 메타데이터 저장에 실패해도 원본 이미지 업로드는 성공한 것으로 처리 (로그만 남김) - logService.log("Failed to save image metadata: ${metaError.message}") - metaError.printStackTrace() + when { + // 1. ADMIN 역할: 모든 글 (비공개, 모든 버전 포함) + roles.contains("ROLE_ADMIN") -> { + posts = postManager.findAllVersionsPaginated(pageable).awaitSingle() + total = postManager.countAllVersions().awaitSingle() } - // --- [신규 로직 끝] --- - - // 썸네일 생성 및 저장 (기존 로직) - Thumbnails.of(originalImagePath) - .width(200) // 가로 크기를 설정 - .keepAspectRatio(true) - .toFile(thumbnailPath) - - // --- [신규 추가 로직 시작] --- - // 업로드가 완료되었으므로, 백그라운드 동기화 작업을 호출합니다. - // (혹시 모를 다른 누락 파일을 찾기 위함. 이미 실행 중이면 무시됨) - imageMetaService.launchSyncTask() - // --- [신규 추가 로직 끝] --- - - // ... (기존 메타데이터 읽기 로그) ... - - } catch (e: IOException) { - e.printStackTrace() - lResultCode = 1 - lResultMsg = "Error: ${e.message}" - } finally { - try { - out?.close() - } catch (e: IOException) { - e.printStackTrace() + // 2. WRITE 역할: 공개된 모든 글 + 자신의 비공개 글 + roles.contains("ROLE_WRITE") && username != null -> { + posts = postManager.findLatestUniqueForWriter(username, pageable).awaitSingle() + total = postManager.countLatestUniqueForWriter(username).awaitSingle() + } + // 3. 그 외 (익명, READ 역할): 공개된 글만 + else -> { + posts = postManager.findLatestUniquePaginated(pageable).awaitSingle() + total = postManager.countLatestUnique().awaitSingle() } } - return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(FileSaveResult().apply { - this.resultCode = lResultCode - this.resultMsg = lResultMsg - this.fileName = "$uuid.$extension" - this.thumbnailName = "${uuid}_thumbnail.$extension" - }) + val processedPosts = posts.map { processPostForView(it) } + vm.modelMap["postsPage"] = PageImpl(processedPosts, pageable, total) + return vm } -// In BlogController.kt -// Add these new functions to your BlogController class - @GetMapping("posts/{postId}/comments.bjx") - fun getCommentsForPost(@PathVariable postId: String): Mono> { - val resultCode = 0 - val resultMsg = "Success" + /** + * [수정됨] 게시물 상세 보기 페이지에 권한 검증 로직을 추가합니다. + */ + @GetMapping("/blog/viewer/{postId}") + suspend fun postViewer( + @PathVariable postId: String, + @AuthenticationPrincipal userDetails: UserDetails? + ): ResultMV { + val vm = ResultMV("content/viewer") + try { + val post = postManager.getPost(postId).awaitSingleOrNull() + ?: return ResultMV("redirect:/blog/posts") + + val isWriter = userDetails?.username == post.writer + val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true + + // 비공개 글(post.posting == false)은 작성자나 관리자만 볼 수 있습니다. + if (!post.posting && !isWriter && !isAdmin) { + logService.log("Access denied for user ${userDetails?.username} to post ${post.id}") + return ResultMV("redirect:/blog/posts") + } + + vm.modelMap["srcPost"] = post + vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(post) + } catch (e: Exception) { + return ResultMV("redirect:/") + } + return vm + } + + /** + * 새 글 작성 및 기존 글 수정을 위한 통합 에디터 페이지를 렌더링합니다. + * 요청 경로에 postId가 있으면 '수정' 모드로, 없으면 '새 글 작성' 모드로 동작합니다. + * + * @param postId URL 경로에서 받는 Post의 ID. 선택 사항입니다. + * @return 에디터 뷰(editor.html)와 렌더링에 필요한 데이터가 담긴 ResultMV 객체 + */ + @GetMapping(value = ["/blog/edit", "/blog/edit/{postId}"]) + suspend fun editPost( + @PathVariable(required = false) postId: String?, + @AuthenticationPrincipal userDetails: UserDetails? + ): ResultMV { + if (userDetails == null) { + return ResultMV("redirect:/home.bs?action=login") + } + + val isAdmin = userDetails.authorities.any { it.authority == "ROLE_ADMIN" } + val canWrite = userDetails.authorities.any { it.authority == "ROLE_WRITE" } + + val vm = ResultMV("content/editor") + try { + if (postId == null) { // 새 글 작성 + if (!canWrite && !isAdmin) { + logService.log("User ${userDetails.username} has no permission to write.") + return ResultMV("redirect:/blog/posts") + } + vm.modelMap["pageTitle"] = "새 글 작성" + + // th:object를 위한 비어있지 않은 Post 객체 생성 + val newPost = Post().apply { + // 사용자의 의도대로 기본 제목을 설정합니다. + title = "무제(無題) (${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(java.util.Date())})" + content = "" // 내용은 비워둡니다. + } + vm.modelMap["srcPost"] = newPost + vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(newPost) + + } else { // 기존 글 수정 + // --- 기존 글 수정 모드 --- + vm.modelMap["pageTitle"] = "글 수정" + val rawPost = postManager.findById(postId).awaitSingleOrNull() + ?: return ResultMV("redirect:/blog/posts") + + val isWriter = userDetails.username == rawPost.writer + if (!isAdmin && !isWriter) { + logService.log("User ${userDetails.username} not authorized to edit post $postId") + return ResultMV("redirect:/blog/posts") + } + val decodedContent = URLDecoder.decode(rawPost.content, "UTF-8") + logService.log("$postId ${decodedContent}") + if (decodedContent.contains("/blog/post/images/")) { + rawPost.content = decodedContent.replace("/blog/post/images/", "/api/images/") + // content가 변경되었으므로 다시 인코딩해서 저장 + rawPost.content = URLEncoder.encode(rawPost.content, "UTF-8") + } + // ==================================================================== + + + // [일관성] URL 디코딩 및 썸네일 경로 생성을 헬퍼 메서드에 위임합니다. + val processedPost = processPostForView(rawPost) + + vm.modelMap["srcPost"] = processedPost + vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(processedPost) + logService.log("Loaded post for editing (ID: $postId)") + } + } catch (e: Exception) { + logService.log("Error processing edit page for postId: $postId. Error: ${e.message}") + return ResultMV("redirect:/blog/posts") + } + return vm + } + + + // =================================================================== + // 2. 데이터 조회 API (GET, AJAX 요청에 JSON 데이터를 응답) + // =================================================================== + + @GetMapping("/blog/rankOfViews.bjx") + @ResponseBody + fun getRankOfViews(): Mono> { + val authentication = SecurityContextHolder.getContext().authentication + val isAnonymous = authentication == null || authentication is AnonymousAuthenticationToken + + val postsFlux: Flux = if (isAnonymous) { + postManager.getTop5UniquePublishedByViews() + } else { + postManager.getTop5AllVersionsByViews() + } + return postsFlux.collectList().map { ResponseEntity.ok(PostListResponse(it)) } + } + + @GetMapping("/blog/recentOfPost.bjx") + @ResponseBody + fun getRecentOfPost(): Mono> { + val authentication = SecurityContextHolder.getContext().authentication + val isAnonymous = authentication == null || authentication is AnonymousAuthenticationToken + + val postsFlux: Flux = if (isAnonymous) { + postManager.getRecent5UniquePublished() + } else { + postManager.getRecent5AllVersions() + } + return postsFlux.collectList().map { ResponseEntity.ok(PostListResponse(it)) } + } + + @GetMapping("/blog/posts/{postId}/comments.bjx") + @ResponseBody + fun getComments(@PathVariable postId: String): Mono { return commentService.getCommentsForPost(postId) .collectList() - .map { commentsList -> - ResponseEntity.ok() - .contentType(MediaType.APPLICATION_JSON) - .body(CommentsResult().apply { - this.resultCode = resultCode - this.resultMsg = resultMsg - this.comments = commentsList - }) - } - .onErrorResume { - Mono.just(ResponseEntity.status(500).body(CommentsResult().apply { - this.resultCode = 500 - this.resultMsg = "Error fetching comments" - this.comments = emptyList() - })) - } + .map { comments -> CommentResponse(0, "Success", comments) } } - @PostMapping("posts/{postId}/comments.bjx") - fun addComment(@PathVariable postId: String, @RequestBody jsonString: String): Mono> { - try { - // 1. Decode the Base64 string to get the envelope JSON - val decodedBytes: ByteArray = Base64.getDecoder().decode(jsonString) + @GetMapping("/blog/comments/{commentId}/replies.bjx") + @ResponseBody + fun getReplies(@PathVariable commentId: String): Mono { + return commentService.getRepliesForComment(commentId) + .collectList() + .map { replies -> CommentResponse(0, "Success", replies) } + } - // 2. Parse the ENVELOPE (RequestModel), just like in your "post.bjx" endpoint - Gson().fromJson(String(decodedBytes), RequestModel::class.java)?.let { model -> - model.data?.let { innerJsonString -> - try { - // 3. COPY the EXACT decryption/un-formatting logic from "post.bjx" - val reqString = innerJsonString.split(GlobalEnvironment.padding(model.getKeyword())) - val nb = arrayListOf() - val na = arrayListOf() - reqString[0].replace(GlobalEnvironment.padding(model.getKeyword()),"").split("").toList().let { na.addAll(it) } - reqString[1].replace(GlobalEnvironment.padding(model.getKeyword()),"").split("").toList().let { nb.addAll(it) } - var max = nb.size + na.size - var fullData = arrayListOf() - for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } } + @GetMapping("/blog/categories.bjx") + @ResponseBody + fun getCategories(): Mono { + // TODO: 향후 DB에서 고유 카테고리 목록을 조회하도록 수정해야 합니다. + val sampleCategories = listOf("일상", "기술", "여행", "요리") + return Mono.just(TagResponse(tags = sampleCategories)) + } - val jsonFromClient = fullData.joinToString("") // This is the REAL comment JSON + @GetMapping("/blog/hashtags.bjx") + @ResponseBody + fun getHashtags(): Mono { + // TODO: 향후 DB에서 고유 해시태그 목록을 조회하도록 수정해야 합니다. + val sampleTags = listOf("Spring", "Kotlin", "React", "MongoDB", "여행") + return Mono.just(TagResponse(tags = sampleTags)) + } - // 4. NOW, parse the decrypted JSON into the Comment object - val comment = Gson().fromJson(jsonFromClient, Comment::class.java).apply { - this.postId = postId - this.writeTime = System.currentTimeMillis() - } - // 5. Save the valid comment object - return commentService.addComment(comment) - .map { - ResponseEntity.ok().body(ResponceResult().apply { - resultCode = 0 - resultMsg = "Comment submitted successfully" - }) - } - .onErrorResume { e -> - Mono.just(ResponseEntity.status(500).body(ResponceResult().apply { - resultCode = 500 - resultMsg = "Error submitting comment: ${e.message}" - })) - } + // =================================================================== + // 3. 데이터 변경 API (POST, 데이터를 생성/수정하고 결과를 JSON으로 응답) + // =================================================================== - } catch (e: Exception) { - // This catches decryption errors - return Mono.just(ResponseEntity.status(400).body(ResponceResult().apply { - resultCode = 400 - resultMsg = "Invalid request data (decryption fail)" - })) - } - } + /** + * [수정됨] 게시물 저장 시 서버에서 다시 한번 권한을 확인합니다. + */ + @PostMapping("/blog/post.bjx") + @ResponseBody + suspend fun savePost( + @RequestBody rawPayload: String, + @AuthenticationPrincipal user: UserDetails? + ): Mono { + if (user == null) { + return Mono.just(PostSaveResponse(401, "Authentication required", null)) + } + + val incomingPost = PayloadDecoder.decode(rawPayload, Post::class.java, objectMapper) + + // 새 글 작성 + if (incomingPost.id.isNullOrBlank()) { + val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" } + val canWrite = user.authorities.any { it.authority == "ROLE_WRITE" } + if (!isAdmin && !canWrite) { + return Mono.just(PostSaveResponse(403, "Permission denied to create post", null)) } - // This catches envelope parsing errors - return Mono.just(ResponseEntity.status(400).body(ResponceResult().apply { - resultCode = 400 - resultMsg = "Invalid request data (model fail)" - })) - } catch (e: Exception) { - // This catches Base64 decoding errors - return Mono.just(ResponseEntity.status(400).body(ResponceResult().apply { - resultCode = 400 - resultMsg = "Invalid request data (base64 fail)" - })) + incomingPost.writer = user.username + incomingPost.writeTime = System.currentTimeMillis() + incomingPost.modifyTime = incomingPost.writeTime + return postManager.save(incomingPost).flatMap { savedPost -> + savedPost.originId = savedPost.id + postManager.save(savedPost) + }.map { finalPost -> PostSaveResponse(0, "Success", PostIdData(finalPost.id!!)) } + } + // 기존 글 수정 (새 버전 생성) + else { + return postManager.findById(incomingPost.id!!).flatMap { originalPost -> + val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" } + val isWriter = user.username == originalPost.writer + if (!isAdmin && !isWriter) { + return@flatMap Mono.just(PostSaveResponse(403, "Permission denied to update post", null)) + } + + val newVersion = incomingPost.copy( + id = null, + originId = originalPost.originId ?: originalPost.id, + writer = originalPost.writer, + writeTime = originalPost.writeTime, + readCount = originalPost.readCount, + voteCount = originalPost.voteCount, + unlikeCount = originalPost.unlikeCount, + modifyTime = System.currentTimeMillis() + ) + postManager.save(newVersion).map { savedPost -> + PostSaveResponse(0, "Success", PostIdData(savedPost.id!!)) + } + }.switchIfEmpty( + Mono.just(PostSaveResponse(404, "Original post to edit not found", null)) + ) } } - @GetMapping("comments/{commentId}/replies.bjx") - fun getRepliesForComment(@PathVariable commentId: String): Mono> { - val resultCode = 0 - val resultMsg = "Success" - - return commentService.getRepliesForComment(commentId) - .collectList() - .map { repliesList -> - ResponseEntity.ok() - .contentType(MediaType.APPLICATION_JSON) - .body(CommentsResult().apply { - this.resultCode = resultCode - this.resultMsg = resultMsg - this.comments = repliesList - }) - } - .onErrorResume { - Mono.just(ResponseEntity.status(500).body(CommentsResult().apply { - this.resultCode = 500 - this.resultMsg = "Error fetching replies" - this.comments = emptyList() - })) - } - } - - /** - * [신규 추가] '좋아요' 버튼 클릭 시 호출될 엔드포인트 - * PostManager를 호출하여 voteCount를 1 증가시키고, 업데이트된 두 카운트를 Map으로 반환합니다. - */ - @PostMapping("post/{postId}/like.bjx") - fun likePost(@PathVariable postId: String): Mono>> { - return postManager.incrementVote(postId) - .map { updatedPost -> - // JS에서 즉시 UI를 업데이트할 수 있도록 최신 카운트를 반환 - ResponseEntity.ok(mapOf("voteCount" to updatedPost.voteCount, "unlikeCount" to updatedPost.unlikeCount)) - } - .defaultIfEmpty(ResponseEntity.notFound().build()) // 해당 ID의 게시물이 없으면 404 - } - - /** - * [신규 추가] '싫어요' 버튼 클릭 시 호출될 엔드포인트 - * PostManager를 호출하여 unlikeCount를 1 증가시키고, 업데이트된 두 카운트를 Map으로 반환합니다. - */ - @PostMapping("post/{postId}/unlike.bjx") - fun unlikePost(@PathVariable postId: String): Mono>> { - return postManager.incrementUnlike(postId) - .map { updatedPost -> - ResponseEntity.ok(mapOf("voteCount" to updatedPost.voteCount, "unlikeCount" to updatedPost.unlikeCount)) - } + // [신규] 게시물 차단 API (관리자 전용) + @PostMapping("/blog/post/{postId}/block") + @PreAuthorize("hasRole('ADMIN')") + @ResponseBody + fun blockPost(@PathVariable postId: String): Mono> { + return postManager.blockPost(postId) + .map { ResponseEntity.ok(it) } .defaultIfEmpty(ResponseEntity.notFound().build()) } + + // [신규] 게시물 차단 해제 API (관리자 전용) + @PostMapping("/blog/post/{postId}/unblock") + @PreAuthorize("hasRole('ADMIN')") + @ResponseBody + fun unblockPost(@PathVariable postId: String): Mono> { + return postManager.unblockPost(postId) + .map { ResponseEntity.ok(it) } + .defaultIfEmpty(ResponseEntity.notFound().build()) + } + + @PostMapping("/blog/post/imageUpload.bjx") + @ResponseBody + fun imageUpload(@RequestParam("file") file: MultipartFile): Mono { + if (uploadPath.isNullOrBlank()) { + return Mono.just(ImageUploadResponse(1, "Upload path not configured", null)) + } + val uniqueFilename = "${UUID.randomUUID()}_${file.originalFilename}" + val targetPath = Paths.get(uploadPath, uniqueFilename) + return try { + Files.createDirectories(targetPath.parent) + Files.copy(file.inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING) + Mono.just(ImageUploadResponse(0, "Success", uniqueFilename)) + } catch (e: IOException) { + Mono.just(ImageUploadResponse(2, "File save failed: ${e.message}", null)) + } + } + + @PostMapping("/blog/posts/{postId}/comments.bjx") + @ResponseBody + fun addComment( + @PathVariable postId: String, + @RequestBody rawPayload: String, // 원시 Base64 문자열을 받음 + @AuthenticationPrincipal user: UserDetails? + ): Mono { + // PayloadDecoder를 사용하여 원본 Comment 객체로 복호화 및 변환 + val comment = PayloadDecoder.decode(rawPayload, Comment::class.java, objectMapper) + + comment.postId = postId + comment.writer = user?.username ?: "Anonymous" + comment.writeTime = System.currentTimeMillis() + + return commentService.addComment(comment) + .map { CommentResponse(0, "Success") } + } + + @PostMapping("/blog/post/{postId}/like.bjx") + @ResponseBody + fun likePost(@PathVariable postId: String): Mono { + return postManager.incrementVote(postId).map { post -> + VoteResponse(post.voteCount, post.unlikeCount) + } + } + + @PostMapping("/blog/post/{postId}/unlike.bjx") + @ResponseBody + fun unlikePost(@PathVariable postId: String): Mono { + return postManager.incrementUnlike(postId).map { post -> + VoteResponse(post.voteCount, post.unlikeCount) + } + } + + + // =================================================================== + // 4. 기타 페이지 및 리다이렉트 + // =================================================================== + + @GetMapping("/login") + fun login(response: HttpServletResponse) { + response.sendRedirect("/user/login") + } + + @GetMapping("/licenses") + fun licenses() = ResultMV("content/licenses") } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt index f977c69..602bec7 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt @@ -1,262 +1,262 @@ -package kr.lunaticbum.back.lun.controllers - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.google.gson.Gson -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import jakarta.servlet.http.HttpServletResponse -import kotlinx.coroutines.reactor.awaitSingle -import kotlinx.coroutines.reactor.awaitSingleOrNull -import kr.lunaticbum.back.lun.model.ImageMeta -import kr.lunaticbum.back.lun.model.ImageMetaService -import kr.lunaticbum.back.lun.model.Post -import kr.lunaticbum.back.lun.model.PostManager -import kr.lunaticbum.back.lun.model.ResultMV -import kr.lunaticbum.back.lun.utils.LogService -import net.coobird.thumbnailator.Thumbnails -import org.commonmark.node.Node -import org.commonmark.parser.Parser -import org.commonmark.renderer.html.HtmlRenderer -import org.jsoup.Jsoup -import org.jsoup.nodes.Element -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Value -import org.springframework.data.domain.Pageable -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import java.io.File -import java.io.IOException -import java.net.URLDecoder - -@RestController -@RequestMapping() -class Home { - - @Autowired - private lateinit var imageMetaService: ImageMetaService - - @Autowired - lateinit var logService: LogService - - @Autowired - private lateinit var postManager: PostManager - - data class PostView( - val id: Long, - val title: String, - val thumb: String?, - val writeTime: Long, - val textOnly: String, - val firstImage: String? - ) - - data class DeltaOp(val insert: Any) - data class Delta(val ops: List) - - fun extractFromDelta(deltaJson: String): Pair { - - val delta: Delta = Gson().fromJson(deltaJson, Delta::class.java) - - var textOnly = StringBuilder() - var firstImage: String? = null - - delta.ops.forEach { op -> - if (op.insert is String) { - textOnly.append(op.insert) - } else if (op.insert is Map<*, *>) { - val obj = op.insert as Map<*, *> - if (obj["image"] != null && firstImage == null) { - firstImage = obj["image"].toString() - } - } - } - return textOnly.toString() to firstImage - } - - @GetMapping("/","/home.bs") - suspend fun home() : ResultMV { - val vm = ResultMV("content/home") - try { - try { - // 1. [수정] awaitSingle() 대신 awaitSingleOrNull()을 사용합니다. - // DB가 비어있으면(이미지 0개) Exception 대신 null을 반환합니다. - val randomImage: ImageMeta? = imageMetaService.getRandomImage().awaitSingleOrNull() // - - // 2. [수정] randomImage 객체가 null이 아닐 경우(성공 시)에만 모델맵에 경로를 추가합니다. - if (randomImage != null) { - vm.modelMap.put("randomBannerImage", randomImage.path) - } - // 3. else (null인 경우): 아무것도 하지 않습니다. - // 뷰(home.html)는 randomBannerImage 변수가 null이므로 기본 CSS 배너를 사용합니다. - - } catch (e: Exception) { - // 4. (Fallback) DB 연결 오류 등 쿼리 자체의 심각한 오류 발생 시 로그만 남깁니다. - logService.log("CRITICAL Error during random banner image query: ${e.message}") - } - - // === [FIXED LOGIC] === - // 1. Asynchronously await the Mono result without blocking the thread. - // Use awaitSingleOrNull() just like the random image query. - val postsList: List = postManager.find8().awaitSingleOrNull() ?: emptyList() - - // 2. Apply the processing logic to the resulting list. - vm.modelMap.put("Posts", postsList.apply { - this.forEach { - it.title = URLDecoder.decode(it.title) - it.content = URLDecoder.decode(it.content) - val parser: Parser = Parser.builder().build() - val document: Node = parser.parse(it.content) - val renderer = HtmlRenderer.builder().build() - println("content >>> ${it.content}") - try { - JsonParser.parseString(it.content) - it.content?.let { content -> - var delta = extractFromDelta(content) - val firstImg = delta.second - it.image = firstImg ?: "images/pic01.jpg" - it.thumb = firstImg ?: "images/pic01.jpg" - it.html = delta.first - } - } catch (e: Exception) { - Jsoup.parse(renderer.render(document))?.let { doc -> - val firstImg: Element? = doc.select("img")?.first() - val imgSrc: String = firstImg?.attr("src") ?: "" - it.image = imgSrc - it.thumb = imgSrc.replace(imgSrc.split("/").last(), imgSrc.split("/").last().replace(".","_thumbnail.")) - generateThumbnail(imgSrc.split("/").last(), 200) - it.html = doc.text() - } - } - it.title = if ((it.title?.length ?: 0) >= 1) it.title else "" - } - }) - // === [END FIXED LOGIC] === - }catch (ex: Exception){ex.printStackTrace()} - vm.modelMap.put("path","/blog/viewer/") - return vm - } - - @Value("\${image.upload.path}") - private val uploadPath: String? = null - - @Value("\${resource.handler}") - private val resourceHandler: String? = null - - fun generateThumbnail(originalPath: String, targetWidth: Int) { - try { - val originalFile = File("$uploadPath${File.separator}$originalPath") - println("origin ${originalPath}") - println("thumb ${originalPath - .replace(".", "_thumbnail.")}") - // 썸네일 경로 생성 (예: /upload/uuid.jpg → /upload/uuid_thumbnail.jpg) - val thumbnailPath = originalPath - .replace(".", "_thumbnail.") - - - val thumbnailFile = File("$uploadPath${File.separator}$thumbnailPath") - // 썸네일 이미 존재하면 종료 - if (thumbnailFile.exists()) { - println("썸네일 이미 존재: $thumbnailPath") - return - } - - - // 원본 파일 존재 확인 - if (!originalFile.exists()) { - println("원본 파일 없음: $originalPath") - return - } - - // 썸네일 생성 (가로 기준 비율 유지) - Thumbnails.of(originalFile) - .width(targetWidth) - .keepAspectRatio(true) - .toFile(thumbnailFile) - - println("썸네일 생성 완료: $thumbnailFile") - } catch (e: IOException) { - println("썸네일 생성 실패: ${e.message}") - } - } - - - @GetMapping("/h2") - fun home2() : ResultMV { - val vm = ResultMV("content/index_ex") - vm.modelMap.put("Posts", postManager.find20().apply { - this.forEach { - it.title = URLDecoder.decode(it.title) - it.content = URLDecoder.decode(it.content) - logService.log(Gson().toJson(it)) - } - }) - return vm - } - - @GetMapping("/left-sidebar") - fun lside() : ResultMV { - val vm = ResultMV("content/left-sidebar") - vm.modelMap.put("Posts", postManager.find20().apply { - this.forEach { - it.title = URLDecoder.decode(it.title) - it.content = URLDecoder.decode(it.content) - logService.log(Gson().toJson(it)) - } - }) - return vm - } - @GetMapping("/no-sidebar") - fun nside() : ResultMV { - val vm = ResultMV("content/no-sidebar") - vm.modelMap.put("Posts", postManager.find20().apply { - this.forEach { - it.title = URLDecoder.decode(it.title) - it.content = URLDecoder.decode(it.content) - logService.log(Gson().toJson(it)) - } - }) - return vm - } - - @GetMapping("/right-sidebar") - fun rside() : ResultMV { - val vm = ResultMV("content/right-sidebar") - vm.modelMap.put("Posts", postManager.find20().apply { - this.forEach { - it.title = URLDecoder.decode(it.title) - it.content = URLDecoder.decode(it.content) - logService.log(Gson().toJson(it)) - } - }) - return vm - } - - @GetMapping("/two-sidebar") - fun bside() : ResultMV { - val vm = ResultMV("content/two-sidebar") - vm.modelMap.put("Posts", postManager.find20().apply { - this.forEach { - it.title = URLDecoder.decode(it.title) - it.content = URLDecoder.decode(it.content) - logService.log(Gson().toJson(it)) - } - }) - return vm - } - - - - - @GetMapping("/login") - fun login(response: HttpServletResponse) { - response.sendRedirect("/user/login") - } - - @GetMapping("/licenses") - fun licenses() : ResultMV { - val vm = ResultMV("content/licenses") - return vm - } - -} \ No newline at end of file +//package kr.lunaticbum.back.lun.controllers +// +//import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +//import com.google.gson.Gson +//import com.google.gson.JsonObject +//import com.google.gson.JsonParser +//import jakarta.servlet.http.HttpServletResponse +//import kotlinx.coroutines.reactor.awaitSingle +//import kotlinx.coroutines.reactor.awaitSingleOrNull +//import kr.lunaticbum.back.lun.model.ImageMeta +//import kr.lunaticbum.back.lun.model.ImageMetaService +//import kr.lunaticbum.back.lun.model.Post +//import kr.lunaticbum.back.lun.model.PostManager +//import kr.lunaticbum.back.lun.model.ResultMV +//import kr.lunaticbum.back.lun.utils.LogService +//import net.coobird.thumbnailator.Thumbnails +//import org.commonmark.node.Node +//import org.commonmark.parser.Parser +//import org.commonmark.renderer.html.HtmlRenderer +//import org.jsoup.Jsoup +//import org.jsoup.nodes.Element +//import org.springframework.beans.factory.annotation.Autowired +//import org.springframework.beans.factory.annotation.Value +//import org.springframework.data.domain.Pageable +//import org.springframework.web.bind.annotation.GetMapping +//import org.springframework.web.bind.annotation.RequestMapping +//import org.springframework.web.bind.annotation.RestController +//import java.io.File +//import java.io.IOException +//import java.net.URLDecoder +// +//@RestController +//@RequestMapping() +//class Home { +// +// @Autowired +// private lateinit var imageMetaService: ImageMetaService +// +// @Autowired +// lateinit var logService: LogService +// +// @Autowired +// private lateinit var postManager: PostManager +// +// data class PostView( +// val id: Long, +// val title: String, +// val thumb: String?, +// val writeTime: Long, +// val textOnly: String, +// val firstImage: String? +// ) +// +// data class DeltaOp(val insert: Any) +// data class Delta(val ops: List) +// +// fun extractFromDelta(deltaJson: String): Pair { +// +// val delta: Delta = Gson().fromJson(deltaJson, Delta::class.java) +// +// var textOnly = StringBuilder() +// var firstImage: String? = null +// +// delta.ops.forEach { op -> +// if (op.insert is String) { +// textOnly.append(op.insert) +// } else if (op.insert is Map<*, *>) { +// val obj = op.insert as Map<*, *> +// if (obj["image"] != null && firstImage == null) { +// firstImage = obj["image"].toString() +// } +// } +// } +// return textOnly.toString() to firstImage +// } +// +// @GetMapping("/","/home.bs") +// suspend fun home() : ResultMV { +// val vm = ResultMV("content/home") +// try { +// try { +// // 1. [수정] awaitSingle() 대신 awaitSingleOrNull()을 사용합니다. +// // DB가 비어있으면(이미지 0개) Exception 대신 null을 반환합니다. +// val randomImage: ImageMeta? = imageMetaService.getRandomImage().awaitSingleOrNull() // +// +// // 2. [수정] randomImage 객체가 null이 아닐 경우(성공 시)에만 모델맵에 경로를 추가합니다. +// if (randomImage != null) { +// vm.modelMap.put("randomBannerImage", randomImage.path) +// } +// // 3. else (null인 경우): 아무것도 하지 않습니다. +// // 뷰(home.html)는 randomBannerImage 변수가 null이므로 기본 CSS 배너를 사용합니다. +// +// } catch (e: Exception) { +// // 4. (Fallback) DB 연결 오류 등 쿼리 자체의 심각한 오류 발생 시 로그만 남깁니다. +// logService.log("CRITICAL Error during random banner image query: ${e.message}") +// } +// +// // === [FIXED LOGIC] === +// // 1. Asynchronously await the Mono result without blocking the thread. +// // Use awaitSingleOrNull() just like the random image query. +// val postsList: List = postManager.find8().awaitSingleOrNull() ?: emptyList() +// +// // 2. Apply the processing logic to the resulting list. +// vm.modelMap.put("Posts", postsList.apply { +// this.forEach { +// it.title = URLDecoder.decode(it.title) +// it.content = URLDecoder.decode(it.content) +// val parser: Parser = Parser.builder().build() +// val document: Node = parser.parse(it.content) +// val renderer = HtmlRenderer.builder().build() +// println("content >>> ${it.content}") +// try { +// JsonParser.parseString(it.content) +// it.content?.let { content -> +// var delta = extractFromDelta(content) +// val firstImg = delta.second +// it.image = firstImg ?: "images/pic01.jpg" +// it.thumb = firstImg ?: "images/pic01.jpg" +// it.html = delta.first +// } +// } catch (e: Exception) { +// Jsoup.parse(renderer.render(document))?.let { doc -> +// val firstImg: Element? = doc.select("img")?.first() +// val imgSrc: String = firstImg?.attr("src") ?: "" +// it.image = imgSrc +// it.thumb = imgSrc.replace(imgSrc.split("/").last(), imgSrc.split("/").last().replace(".","_thumbnail.")) +// generateThumbnail(imgSrc.split("/").last(), 200) +// it.html = doc.text() +// } +// } +// it.title = if ((it.title?.length ?: 0) >= 1) it.title else "" +// } +// }) +// // === [END FIXED LOGIC] === +// }catch (ex: Exception){ex.printStackTrace()} +// vm.modelMap.put("path","/blog/viewer/") +// return vm +// } +// +// @Value("\${image.upload.path}") +// private val uploadPath: String? = null +// +// @Value("\${resource.handler}") +// private val resourceHandler: String? = null +// +// fun generateThumbnail(originalPath: String, targetWidth: Int) { +// try { +// val originalFile = File("$uploadPath${File.separator}$originalPath") +// println("origin ${originalPath}") +// println("thumb ${originalPath +// .replace(".", "_thumbnail.")}") +// // 썸네일 경로 생성 (예: /upload/uuid.jpg → /upload/uuid_thumbnail.jpg) +// val thumbnailPath = originalPath +// .replace(".", "_thumbnail.") +// +// +// val thumbnailFile = File("$uploadPath${File.separator}$thumbnailPath") +// // 썸네일 이미 존재하면 종료 +// if (thumbnailFile.exists()) { +// println("썸네일 이미 존재: $thumbnailPath") +// return +// } +// +// +// // 원본 파일 존재 확인 +// if (!originalFile.exists()) { +// println("원본 파일 없음: $originalPath") +// return +// } +// +// // 썸네일 생성 (가로 기준 비율 유지) +// Thumbnails.of(originalFile) +// .width(targetWidth) +// .keepAspectRatio(true) +// .toFile(thumbnailFile) +// +// println("썸네일 생성 완료: $thumbnailFile") +// } catch (e: IOException) { +// println("썸네일 생성 실패: ${e.message}") +// } +// } +// +// +// @GetMapping("/h2") +// fun home2() : ResultMV { +// val vm = ResultMV("content/index_ex") +// vm.modelMap.put("Posts", postManager.find20().apply { +// this.forEach { +// it.title = URLDecoder.decode(it.title) +// it.content = URLDecoder.decode(it.content) +// logService.log(Gson().toJson(it)) +// } +// }) +// return vm +// } +// +// @GetMapping("/left-sidebar") +// fun lside() : ResultMV { +// val vm = ResultMV("content/left-sidebar") +// vm.modelMap.put("Posts", postManager.find20().apply { +// this.forEach { +// it.title = URLDecoder.decode(it.title) +// it.content = URLDecoder.decode(it.content) +// logService.log(Gson().toJson(it)) +// } +// }) +// return vm +// } +// @GetMapping("/no-sidebar") +// fun nside() : ResultMV { +// val vm = ResultMV("content/no-sidebar") +// vm.modelMap.put("Posts", postManager.find20().apply { +// this.forEach { +// it.title = URLDecoder.decode(it.title) +// it.content = URLDecoder.decode(it.content) +// logService.log(Gson().toJson(it)) +// } +// }) +// return vm +// } +// +// @GetMapping("/right-sidebar") +// fun rside() : ResultMV { +// val vm = ResultMV("content/right-sidebar") +// vm.modelMap.put("Posts", postManager.find20().apply { +// this.forEach { +// it.title = URLDecoder.decode(it.title) +// it.content = URLDecoder.decode(it.content) +// logService.log(Gson().toJson(it)) +// } +// }) +// return vm +// } +// +// @GetMapping("/two-sidebar") +// fun bside() : ResultMV { +// val vm = ResultMV("content/two-sidebar") +// vm.modelMap.put("Posts", postManager.find20().apply { +// this.forEach { +// it.title = URLDecoder.decode(it.title) +// it.content = URLDecoder.decode(it.content) +// logService.log(Gson().toJson(it)) +// } +// }) +// return vm +// } +// +// +// +// +// @GetMapping("/login") +// fun login(response: HttpServletResponse) { +// response.sendRedirect("/user/login") +// } +// +// @GetMapping("/licenses") +// fun licenses() : ResultMV { +// val vm = ResultMV("content/licenses") +// return vm +// } +// +//} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt index c3a11a2..3c53b9a 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt @@ -1,6 +1,7 @@ package kr.lunaticbum.back.lun.controllers import com.google.gson.Gson +import com.google.protobuf.LazyStringArrayList.emptyList import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import kr.lunaticbum.back.lun.configs.GlobalEnvironment @@ -13,27 +14,38 @@ import kr.lunaticbum.back.lun.utils.JwtUtil import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.extractModelData import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.domain.PageRequest import org.springframework.http.MediaType import org.springframework.http.ResponseCookie import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.core.userdetails.UserDetails import org.springframework.web.bind.annotation.* import org.springframework.web.reactive.function.client.WebClient import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.Authentication +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.web.authentication.RememberMeServices import org.springframework.security.web.context.HttpSessionSecurityContextRepository +import reactor.core.publisher.Mono import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.* import javax.naming.AuthenticationException +import kotlin.collections.emptyList @RestController @RequestMapping("/user") class UserController( - private val rememberMeServices: RememberMeServices + private val rememberMeServices: RememberMeServices, + private val userManager: UserManager, // 의존성 주입 추가 + private val postManager: PostManager, // 의존성 주입 추가 + private val commentService: CommentService // 의존성 주입 추가 ) { @@ -43,8 +55,6 @@ class UserController( @Autowired lateinit var logService: LogService - @Autowired - lateinit var userManager: UserManager @GetMapping("join.bs") fun hello(httpServletRequest: HttpServletRequest): ResultMV { @@ -223,4 +233,80 @@ class UserController( .retrieve() .bodyToMono(String::class.java).block() ?: "FAIL" } + + /** + * [신규 추가] '내 정보' 페이지를 위한 핸들러 + */ + @GetMapping("/info") + suspend fun myInfoPage(@AuthenticationPrincipal userDetails: UserDetails?): ResultMV { + if (userDetails == null) { + return ResultMV("redirect:/home.bs?action=login") + } + val vm = ResultMV("content/user/my_info") + val username = userDetails.username + + // 1. 기본 유저 정보 조회 + val user = userManager.findById(username)?.block() + if (user != null) { + // 가입일을 보기 좋은 형식으로 변환하여 모델에 추가 + val joinDate = Instant.ofEpochMilli(user.user_join) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + vm.modelMap["user"] = user + vm.modelMap["joinDate"] = joinDate.format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일")) + } + + // 2. 내가 쓴 글 목록 조회 (최신 10개) + val myPosts = postManager.findPostsByWriter(username, PageRequest.of(0, 10)).collectList().block() + vm.modelMap["myPosts"] = myPosts ?: emptyList() + + // 3. 내가 쓴 댓글 목록 조회 (최신 10개) + val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block() + vm.modelMap["myComments"] = myComments ?: emptyList() + vm.modelMap["pageTitle"] = "내 정보" // 동적 페이지 제목 설정 + + val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true + vm.modelMap["isAdmin"] = isAdmin + + if (isAdmin) { + // 관리자일 경우, 추가 정보 조회 + vm.modelMap["allUsers"] = userManager.findAllUsers().collectList().block() + vm.modelMap["permissionRequests"] = userManager.findUsersRequestingWritePermission().collectList().block() + vm.modelMap["allRecentPosts"] = postManager.findAllVersionsPaginated(PageRequest.of(0, 20)).block() // 모든 글 조회 + } + + return vm + } + + // [신규] 글쓰기 권한 요청 API + @PostMapping("/request-write") + @ResponseBody + fun requestWrite(@AuthenticationPrincipal userDetails: UserDetails?): Mono> { + if (userDetails == null) return Mono.just(ResponseEntity.status(401).build()) + return userManager.requestWritePermission(userDetails.username) + .map { ResponseEntity.ok("요청이 완료되었습니다.") } + .defaultIfEmpty(ResponseEntity.status(404).body("사용자를 찾을 수 없습니다.")) + } + + + // [신규] 글쓰기 권한 승인 API (관리자 전용) + @PostMapping("/approve-writer/{userId}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseBody + fun approveWriter(@PathVariable userId: String): Mono> { + return userManager.approveWritePermission(userId) + .map { ResponseEntity.ok(it) } + .defaultIfEmpty(ResponseEntity.notFound().build()) + } + + // [신규] 글쓰기 권한 거절 API (관리자 전용) + @PostMapping("/reject-writer/{userId}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseBody + fun rejectWriter(@PathVariable userId: String): Mono> { + return userManager.rejectWritePermission(userId) + .map { ResponseEntity.ok(it) } + .defaultIfEmpty(ResponseEntity.notFound().build()) + } + } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt index 10d1299..a31b804 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt @@ -1,14 +1,18 @@ package kr.lunaticbum.back.lun.model +import com.fasterxml.jackson.databind.ObjectMapper +import kr.lunaticbum.back.lun.configs.GlobalEnvironment import kr.lunaticbum.back.lun.utils.LogService import lombok.AllArgsConstructor import lombok.Data +import lombok.Getter import lombok.NoArgsConstructor import okio.Timeout import org.bson.BsonType import org.bson.codecs.pojo.annotations.BsonId import org.bson.codecs.pojo.annotations.BsonRepresentation import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.annotation.Id import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable @@ -31,60 +35,57 @@ import java.time.Duration import org.springframework.data.mongodb.core.index.CompoundIndex // [신규 추가] import org.springframework.data.mongodb.core.index.IndexDirection // [신규 추가] import org.springframework.data.mongodb.core.index.Indexed // [신규 추가] +import java.util.Base64 -@Data -@NoArgsConstructor -@AllArgsConstructor @Document(collection = "Post") @CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}") -class Post { +data class Post( @BsonId @BsonRepresentation(BsonType.OBJECT_ID) - var id: String? = null + var id: String? = null, - var originId: String? = null + var originId: String? = null, - var title : String? = null - var content : String? = null - var category : String? = null - var tags : String? = null + var title : String? = null, + var content : String? = null, + var category : String? = null, + var tags : String? = null, - var html : String? = null - var image : String? = null - var thumb : String? = null + var html : String? = null, + var image : String? = null, + var thumb : String? = null, - var writer : String? = null - var writeTime : Long = 0 - var posting : Boolean = false - var firstPostLat : Double = 0.0 - var firstPostLon : Double = 0.0 - var firstAddress = "" - var modifyAddress = "" + var writer : String? = null, + var writeTime : Long = 0, + var posting : Boolean = false, + var firstPostLat : Double = 0.0, + var firstPostLon : Double = 0.0, + var firstAddress : String = "", + var modifyAddress : String = "", - // [수정] 모든 정렬(Sort) 쿼리의 핵심이므로 내림차순 인덱스 추가 @Indexed(direction = IndexDirection.DESCENDING) - var modifyTime : Long = 0 - var modifyLat : Double = 0.0 - var modifyLon : Double = 0.0 + var modifyTime : Long = 0, + var modifyLat : Double = 0.0, + var modifyLon : Double = 0.0, - // [수정] 인기글(rankOfViews) 조회 쿼리를 위한 내림차순 인덱스 추가 @Indexed(direction = IndexDirection.DESCENDING) - var readCount : Long = 0 - var voteCount : Long = 0 - var unlikeCount : Long = 0 -} + var readCount : Long = 0, + var voteCount : Long = 0, + var unlikeCount : Long = 0, + var isBlocked: Boolean = false +) @Document(collection = "Comment") -class Comment { +data class Comment ( @BsonId - var id: String? = null - var postId: String? = null // 댓글이 달린 포스트의 id - var parentId: String? = null // 대댓글이면 상위 댓글의 id, 최상위 댓글이면 null - var writer: String? = null - var content: String? = null - var writeTime: Long? = null + var id: String? = null, + var postId: String? = null , // 댓글이 달린 포스트의 id + var parentId: String? = null ,// 대댓글이면 상위 댓글의 id, 최상위 댓글이면 null + var writer: String? = null, + var content: String? = null, + var writeTime: Long? = null, var mentions: List? = null // 언급된 유저 아이디(선택) -} +) @Data @NoArgsConstructor @@ -115,6 +116,7 @@ data class AggregationCount(val totalCount: Long) interface CommentRepository : ReactiveMongoRepository { fun findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId: String): Flux // 최상위 댓글 fun findByParentIdOrderByWriteTimeAsc(parentId: String): Flux + fun findByWriterOrderByWriteTimeDesc(writer: String, pageable: Pageable): Flux // [신규 추가] } @Service @@ -130,7 +132,9 @@ class CommentService(private val commentRepository: CommentRepository) { fun getCommentsForPost(postId: String): Flux { return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId) } - + fun findCommentsByWriter(writer: String, pageable: Pageable): Flux { // [신규 추가] + return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable) + } // 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능 } @@ -143,6 +147,28 @@ interface PostRepository : ReactiveMongoRepository { fun findTop5ByOrderByReadCountDesc(): Flux fun findTop5ByOrderByModifyTimeDesc(): Flux + // [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상) + @Aggregation(pipeline = [ + "{ \$match: { posting: true, isBlocked: false } }", // [수정됨] + "{ \$sort: { modifyTime: -1 } }", // 2. 최신 버전이 먼저 오도록 정렬 + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", // 3. 고유 포스트 그룹화 + "{ \$replaceRoot: { newRoot: \"\$post\" } }", // 4. 그룹화된 문서를 원래 형태로 복원 + "{ \$sort: { readCount: -1 } }", // 5. 조회수 순으로 정렬 + "{ \$limit: 5 }" // 6. 상위 5개만 선택 + ]) + fun findTop5UniquePublishedByReadCountDesc(): Flux + + // [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상) + @Aggregation(pipeline = [ + "{ \$match: { posting: true, isBlocked: false } }", // [수정됨] + "{ \$sort: { modifyTime: -1 } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + "{ \$replaceRoot: { newRoot: \"\$post\" } }", + "{ \$sort: { modifyTime: -1 } }", // 최신순으로 다시 정렬 + "{ \$limit: 5 }" + ]) + fun findTop5UniquePublishedByModifyTimeDesc(): Flux + /** * 익명 사용자를 위한 '고유 최신 글' 목록을 페이지네이션으로 조회합니다. @@ -166,6 +192,22 @@ interface PostRepository : ReactiveMongoRepository { ]) fun countLatestUniqueOrigin(): Mono // 헬퍼 클래스로 매핑 + @Aggregation(pipeline = [ + "{ \$match: { \$or: [ { writer: ?0 }, { posting: true } ] } }", + "{ \$sort: { modifyTime: -1 } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + "{ \$replaceRoot: { newRoot: \"\$post\" } }", + "{ \$sort: { \"modifyTime\": -1 } }" + ]) + fun findLatestUniqueForWriterPaginated(username: String, pageable: Pageable): Flux + + @Aggregation(pipeline = [ + "{ \$match: { \$or: [ { writer: ?0 }, { posting: true } ] } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }", + "{ \$count: \"totalCount\" }" + ]) + fun countLatestUniqueForWriter(username: String): Mono + /** * 익명 사용자를 위한 '고유 최신 글' 목록을 페이지네이션으로 조회합니다. @@ -194,6 +236,9 @@ interface PostRepository : ReactiveMongoRepository { "{ \$count: \"totalCount\" }" ]) fun countLatestUniquePublished(): Mono // 메서드 이름 변경 + + fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux // [신규 추가] + } @@ -208,6 +253,47 @@ class PostManager( @Autowired private lateinit var bCryptPasswordEncoder: PasswordEncoder + // [신규] 게시물 차단 + fun blockPost(postId: String): Mono { + return postRepository.findById(postId).flatMap { post -> + post.isBlocked = true + postRepository.save(post) + } + } + + // [신규] 게시물 차단 해제 + fun unblockPost(postId: String): Mono { + return postRepository.findById(postId).flatMap { post -> + post.isBlocked = false + postRepository.save(post) + } + } + + fun findById(id: String): Mono { + return postRepository.findById(id) + } + + /** + * [신규 추가] '글쓰기' 권한 사용자를 위한 메서드 (고유 최신 글 + 자신의 글 페이지네이션 조회) + */ + fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono> { + return postRepository.findLatestUniqueForWriterPaginated(username, pageable) + .collectList() + } + + fun findPostsByWriter(writer: String, pageable: Pageable): Flux { // [신규 추가] + return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable) + } + + /** + * [신규 추가] '글쓰기' 권한 사용자가 보는 글의 총 개수 + */ + fun countLatestUniqueForWriter(username: String): Mono { + return postRepository.countLatestUniqueForWriter(username) + .map { it.totalCount } + .switchIfEmpty(Mono.just(0L)) + } + fun getPost(id: String): Mono { val query = Query.query(Criteria.where("id").`is`(id)) val update = Update().inc("readCount", 1) @@ -334,5 +420,147 @@ class PostManager( } } + // [기존] 로그인 사용자용 인기글 (메서드 이름 명확화: getTop5 -> getTop5AllVersions) + fun getTop5AllVersionsByViews(): Flux { + return postRepository.findTop5ByOrderByReadCountDesc().map { p -> + p.title = URLDecoder.decode(p.title, "UTF-8") + if (p.title?.isEmpty() == true) { + p.title = "무제(無題)" + } + p + } + } -} \ No newline at end of file + // [기존] 로그인 사용자용 최신글 (메서드 이름 명확화) + fun getRecent5AllVersions(): Flux { + return postRepository.findTop5ByOrderByModifyTimeDesc().map { p -> + p.title = URLDecoder.decode(p.title, "UTF-8") + if (p.title?.isEmpty() == true) { + p.title = "무제(無題)" + } + p + } + } + + // [신규 추가] 익명 사용자용 인기글 + fun getTop5UniquePublishedByViews(): Flux { + return postRepository.findTop5UniquePublishedByReadCountDesc().map { p -> + p.title = URLDecoder.decode(p.title, "UTF-8") + if (p.title?.isEmpty() == true) { + p.title = "무제(無題)" + } + p + } + } + + // [신규 추가] 익명 사용자용 최신글 + fun getRecent5UniquePublished(): Flux { + return postRepository.findTop5UniquePublishedByModifyTimeDesc().map { p -> + p.title = URLDecoder.decode(p.title, "UTF-8") + if (p.title?.isEmpty() == true) { + p.title = "무제(無題)" + } + p + } + } + +} + + +/** + * common.js에서 Base64 인코딩 후 전송하는 데이터의 구조와 일치하는 DTO입니다. + * @param data 난독화된 실제 데이터 문자열 + * @param key 데이터를 재조합하는 데 사용되는 키 + * @param type 난독화 방식을 결정하는 타입 + */ +data class EncryptedPayload( + val data: String = "", + val key: String = "", + val type: String = "" +) + +@Getter +class RequestModel { + var type : String? = null + var key : String? = null + var data : String? = null + + fun getKeyword() = key ?: "" + + fun extractData() : String { + data?.let { + val reqString = data?.split(GlobalEnvironment.padding(getKeyword())) + val nb = arrayListOf() + val na = arrayListOf() + reqString?.get(0)?.replace(GlobalEnvironment.padding(getKeyword()),"")?.split("")?.toList()?.let { na.addAll(it) } + reqString?.get(1)?.replace(GlobalEnvironment.padding(getKeyword()),"")?.split("")?.toList()?.let { nb.addAll(it) } + val max = nb.size + na.size + val fullData = arrayListOf() + for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } } + return fullData.joinToString("") + } + return "" + } +} + + +@Getter +class ReportModel { + var name : String? = null + var email : String? = null + var message : String? = null +} + +object PayloadDecoder { + + /** + * common.js의 unformat() 함수와 대칭되는 복호화 로직입니다. + * @param data 난독화된 데이터 + * @param key 분리 기준이 되는 키 + * @param type 복호화 방식을 결정하는 타입 + * @return 원본 데이터 문자열 + */ + private fun format(data: String, key: String, type: String): String { + val divider = "|*-*|$key|*-*|" + var (odd, even) = data.split(divider).let { it[0] to it[1] } + + // 타입에 따라 unformat에서 적용된 reverse()를 다시 reverse()하여 원상복구 + when (type) { + "T1" -> odd = odd.reversed() + "T2" -> even = even.reversed() + "T3" -> { + odd = odd.reversed() + even = even.reversed() + } + } + + // odd와 even 문자열을 다시 조합하여 원본 데이터 생성 + val result = StringBuilder() + val maxLength = maxOf(odd.length, even.length) + for (i in 0 until maxLength) { + if (i < even.length) result.append(even[i]) + if (i < odd.length) result.append(odd[i]) + } + return result.toString() + } + + /** + * Base64로 인코딩된 전체 payload를 디코딩하고, 최종적으로 원하는 객체 타입으로 변환합니다. + * @param payload 컨트롤러가 받은 원시 Base64 문자열 + * @param clazz 변환하고자 하는 최종 클래스 타입 (예: Post::class.java) + * @return 변환된 객체 + */ + fun decode(payload: String, clazz: Class, objectMapper: ObjectMapper): T { + // 1. Base64 디코딩 -> { "data": ..., "key": ..., "type": ... } 형태의 JSON 문자열이 됨 + val b64Decoded = String(Base64.getDecoder().decode(payload)) + + // 2. 외부 JSON을 EncryptedPayload DTO로 변환 + val encryptedPayload = objectMapper.readValue(b64Decoded, EncryptedPayload::class.java) + + // 3. 내부의 난독화된 데이터를 복호화하여 원본 JSON 문자열을 얻음 + val originalJson = format(encryptedPayload.data, encryptedPayload.key, encryptedPayload.type) + + // 4. 원본 JSON을 최종 목표 객체로 변환하여 반환 + return objectMapper.readValue(originalJson, clazz) + } +} diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/RequestModel.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/RequestModel.kt deleted file mode 100644 index f85ec01..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/RequestModel.kt +++ /dev/null @@ -1,36 +0,0 @@ -package kr.lunaticbum.back.lun.model - -import kr.lunaticbum.back.lun.configs.GlobalEnvironment -import lombok.Getter - -@Getter -class RequestModel { - var type : String? = null - var key : String? = null - var data : String? = null - - fun getKeyword() = key ?: "" - - fun extractData() : String { - data?.let { - val reqString = data?.split(GlobalEnvironment.padding(getKeyword())) - val nb = arrayListOf() - val na = arrayListOf() - reqString?.get(0)?.replace(GlobalEnvironment.padding(getKeyword()),"")?.split("")?.toList()?.let { na.addAll(it) } - reqString?.get(1)?.replace(GlobalEnvironment.padding(getKeyword()),"")?.split("")?.toList()?.let { nb.addAll(it) } - val max = nb.size + na.size - val fullData = arrayListOf() - for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } } - return fullData.joinToString("") - } - return "" - } -} - - -@Getter -class ReportModel { - var name : String? = null - var email : String? = null - var message : String? = null -} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt index 5831f01..9798adf 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt @@ -16,6 +16,7 @@ import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Repository import org.springframework.stereotype.Service +import reactor.core.publisher.Flux import reactor.core.publisher.Mono import java.time.Duration @@ -24,27 +25,27 @@ import java.time.Duration @NoArgsConstructor @AllArgsConstructor @Document(collection = "User") -class User { +data class User ( @BsonId @BsonRepresentation(BsonType.OBJECT_ID) - var userId: String? = null + var userId: String? = null, @Id - var user_id: String? = null - var user_pw: String? = null - var user_pw_check: String? = null + var user_id: String? = null, + var user_pw: String? = null, + var user_pw_check: String? = null, - var user_email: String? = null + var user_email: String? = null, @CreatedDate - var user_join: Long = 0L + var user_join: Long = 0L, // var user_name: String? = null - var isAccept : String? = null - var isAdmin : String? = null - - var rememberMe : Boolean? = false + var isAccept : String? = null, + var isAdmin : String? = null, + var rememberMe : Boolean? = false, + var writePermissionRequested: Boolean = false) { fun checkValid() : Boolean { if ( ((user_id?.length ?: 0) > 5) && @@ -116,7 +117,7 @@ interface UserRepository : ReactiveMongoRepository { // @Query("{user_email :?0}") // fun findByEmail(user_email: String): Mono - + fun findByWritePermissionRequested(requested: Boolean): Flux // [신규 추가] fun save(user: User): Mono } @@ -148,6 +149,22 @@ class UserManager( return userRepository.findById(id) } + // [신규] 글쓰기 권한 승인 + fun approveWritePermission(userId: String): Mono { + return userRepository.findById(userId).flatMap { user -> + user.isAccept = "Y" + user.writePermissionRequested = false + userRepository.save(user) + } + } + + // [신규] 글쓰기 권한 요청 거절 + fun rejectWritePermission(userId: String): Mono { + return userRepository.findById(userId).flatMap { user -> + user.writePermissionRequested = false + userRepository.save(user) + } + } fun save(user: User): Mono { @@ -164,10 +181,33 @@ class UserManager( override fun loadUserByUsername(username: String?): UserDetails { logService.log("username ${username}") var user = findById(username!!)?.blockOptional(Duration.ofMillis(5000L))?.get() ?: User() -// user.hashPassword(passwordEncoder) + + val userRole = user.getRole().name // "READ", "WRITE", 또는 "ADMIN" + + return org.springframework.security.core.userdetails.User.builder() .username(user.user_id ?: "") .password(user.user_pw) - .roles(if ("Y".equals(user.isAdmin)) Role.ADMIN.name else {Role.USER.name}).build() + .roles(userRole) + .build() } + + // [신규] 모든 사용자 목록 조회 + fun findAllUsers(): Flux { + return userRepository.findAll() + } + + // [신규] 글쓰기 권한을 요청한 사용자 목록 조회 + fun findUsersRequestingWritePermission(): Flux { + return userRepository.findByWritePermissionRequested(true) + } + + // [신규] 글쓰기 권한 요청 + fun requestWritePermission(userId: String): Mono { + return userRepository.findById(userId).flatMap { user -> + user.writePermissionRequested = true + userRepository.save(user) + } + } + } \ No newline at end of file diff --git a/src/main/resources/static/css/common.css b/src/main/resources/static/css/common.css deleted file mode 100644 index 87edb42..0000000 --- a/src/main/resources/static/css/common.css +++ /dev/null @@ -1,551 +0,0 @@ -/* - * MODIFIED COMMON.CSS - * This file is refactored to inherit styles and variables from main.css. - * It styles custom components (popups, editor, etc.) to match the Arcana theme. - */ - -/* * Removed conflicting global 'html, body' styles. - * The site will now correctly use the font and background from main.css. - */ - -/* - * --- POPUP AND DIM LAYER STYLES --- - * Reworked to use variables and styles from main.css for a consistent look. -*/ -.pop_layer { - display: none; - position: fixed; /* Use fixed for proper viewport centering */ - top: 50%; - left: 50%; - transform: translate(-50%, -50%); /* Modern centering method */ - width: 450px; - max-width: 90%; /* Ensure it's responsive on small screens */ - height: auto; - background-color: var(--pure-white, #fff); /* Use theme's white variable */ - border: 1px solid #e0e0e0; /* Use theme's standard border color */ - box-shadow: 0 0 15px rgba(0, 0, 0, 0.15); /* Add a subtle shadow */ - z-index: 1001; /* Ensure it's above the dim layer */ - border-radius: 5px; /* Use theme's standard border-radius */ -} - -.pop_layer .pop_container { - padding: 2em; -} - -.pop_layer .pop_conts h2 { - font-size: 1.75em; /* Match theme's h2 style */ - margin-bottom: 1em; - text-align: center; -} - -.pop_layer p.ctxt { - color: var(--font-color_default, #474747); /* Use theme's text color */ - line-height: 1.65em; -} - -.pop_layer .btn_r { - width: 100%; - margin-top: 1.5em; - padding-top: 1em; - border-top: 1px solid #e0e0e0; /* Use theme's divider color */ - text-align: right; -} - -/* Style the close button to match the theme's alternate button style */ -a.btn_layerClose { - display: inline-block; - padding: 0 1.5em; - line-height: 2.75em; - text-decoration: none; - font-weight: 600; - border-radius: 5px; - cursor: pointer; - background-color: var(--button-alt-default, #555); - color: var(--pure-white, #fff); - transition: background-color 0.2s ease-in-out; -} - -a.btn_layerClose:hover { - background-color: var(--button-alt-hover, #626262); - color: var(--pure-white, #fff) !important; /* Ensure hover color override */ -} - - -.dim_layer { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 1000; - background-color: rgba(0, 0, 0, 0.4); /* Slightly softer dim */ -} - -/* * --- LOGIN FORM STYLES --- - * Adapted to use the default form input styles from main.css. -*/ -#loginFormElement input[type="text"], -#loginFormElement input[type="password"] { - margin-bottom: 1em; /* Add spacing between fields */ -} - -#loginFormElement button { - margin-top: 1em; - width: 100%; -} - -/* Custom Checkbox Styling */ -#loginFormElement span { - vertical-align: middle; - margin-left: 0.5em; -} - -#rememberMe { - vertical-align: middle; - width: 22px; - height: 22px; - appearance: none; - -webkit-appearance: none; - border: 1px solid #e0e0e0; - border-radius: 5px; - background-color: var(--pure-white, #fff); - cursor: pointer; - position: relative; - top: -2px; -} - -#rememberMe:checked { - background-color: var(--point-color, #FFA500); - border-color: var(--point-color, #FFA500); -} - -#rememberMe:checked::after { - content: ''; - position: absolute; - top: 2px; - left: 7px; - width: 6px; - height: 12px; - border: solid var(--pure-white, #fff); - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} - -/* * --- BLOG POST & EDITOR STYLES --- -*/ - -/* Control box below the editor in viewer.html */ -.write_controllbox { - margin-top: 2em; - padding: 0; - display: flex; - flex-direction: row; /* [수정] 아이템을 가로(row)로 정렬합니다. */ - align-items: stretch; /* [수정] 모든 박스가 동일한 높이를 갖도록 stretch로 변경 */ - list-style: none; - gap: 15px; /* Use gap for spacing */ -} - -.write_option { - display: flex; - flex-direction: column; /* [수정] 자식(제목, 내용)을 세로로 쌓습니다. */ - justify-content: flex-start; /* 내용을 상단부터 표시합니다. */ - align-items: flex-start; /* 모든 내용을 왼쪽 정렬합니다. */ - - flex: 1 1 0%; /* [핵심] 1:1:1 비율로 동일한 너비를 갖도록 설정 */ - min-width: 0; /* [추가] flex item이 내용 래핑을 위해 축소될 수 있도록 허용 */ - - padding: 0.75em; - margin: 0; - background: var(--pure-white, #fff); - border: solid 1px #e0e0e0; - border-radius: 5px; - color: inherit; - min-height: 48px; -} - - -/* * --- QUILL EDITOR THEME OVERRIDE --- - * Modified to blend with the Arcana theme's light background. -*/ - -/* Define custom fonts for Quill to match the site */ -.ql-font-source-sans-pro { font-family: 'Source Sans Pro', sans-serif; } -/* Add any other fonts you've whitelisted in common.js */ - -.ql-toolbar.ql-snow { - background: var(--almost-white, #f7f7f7); /* Use theme's light gray */ - border: 1px solid #e0e0e0; - border-bottom: none; /* Connect toolbar to editor visually */ - border-radius: 5px 5px 0 0; - padding: 12px 8px; -} - -.ql-container.ql-snow { - min-width: unset; - background: var(--pure-white, #fff); - border: 1px solid #e0e0e0; - border-radius: 0 0 5px 5px; - color: var(--font-color_default, #474747); -} -/* * --- QUILL TOOLBAR SEPARATOR (구분선) --- - * 기본 'margin-right: 15px' 대신 패딩과 보더로 구분선을 추가합니다. -*/ - - -.ql-toolbar.ql-snow .ql-formats { - margin-right: unset; -} - -.ql-toolbar.ql-snow .ql-formats { - min-width: unset; - margin-right: 8px; /* 기본 마진 감소 */ - padding-right: 8px; /* 보더 오른쪽에 패딩 추가 */ - border-right: 1px solid #e0e0e0; /* 테마의 보더 색상으로 구분선 추가 */ -} - -/* 툴바의 마지막 그룹에는 구분선과 불필요한 여백을 제거합니다. */ -.ql-toolbar.ql-snow .ql-formats:last-child { - min-width: unset; - margin-right: 0; - padding-right: 0; - border-right: none; -} - -/* Ensure editor content uses the theme's default font and size */ -.ql-editor { - font-family: 'Source Sans Pro', sans-serif; - font-size: 1rem; /* Base size */ - line-height: 1.65em; -} - - -.ql-snow.ql-toolbar button, .ql-snow .ql-toolbar button { - background: none; - border: none; - cursor: pointer; - display: inline-block; - float: left; - height: 24px; - padding: 3px 5px; - min-width: unset; -} - -.ql-snow .ql-picker-label { - cursor: pointer; - display: inline-block; - height: 100%; - padding-left: unset; - padding-right: unset; - position: relative; - width: 100%; -} -/* - * --- COMMENT SECTION --- - * Styled to fit the main theme. -*/ -.comment-section { - margin-top: 3em; - padding-top: 2em; - border-top: 1px solid #e0e0e0; -} - -#comment-form-container { - display: flex; - flex-direction: column; - margin-bottom: 2em; -} - -/* Inherits styles from main.css 'textarea' selector */ -#comment-input { - min-height: 100px; - margin-bottom: 1em; -} - -#comments-list .comment { - background: var(--almost-white, #f7f7f7); - border-radius: 5px; - padding: 1em 1.5em; - margin-bottom: 1em; - border: 1px solid #e0e0e0; -} - -.comment-header { - font-weight: 600; - color: var(--font-color_default, #474747); - margin-bottom: 0.5em; -} - -.comment-time { - font-size: 0.9em; - color: #999; - margin-left: 0.5em; -} - -.comment-content { - line-height: 1.65em; - white-space: pre-wrap; - word-wrap: break-word; -} - -/* 컨트롤 박스 내부 공통 제목 스타일 */ -.write_option .tag-title { - font-weight: 600; - margin-bottom: 0.35em; /* [수정] 제목과 내용 사이에 하단 여백을 줍니다. */ - color: #555; - font-size: 0.85em; /* [추가] 제목 라벨 폰트를 살짝 작게 */ - text-transform: uppercase; /* [추가] 라벨처럼 보이도록 영문 대문자 처리 */ - - /* 태그 제목(HASHTAGS)이 태그 아이템과 동일한 스타일을 공유할 경우 대비 (선택 사항) */ - display: block; - width: 100%; -} - -/* 컨트롤 박스 내부 공통 태그 아이템 스타일 (뷰어 모드용) */ -.write_option .tag-item { - display: inline-block; - background-color: var(--almost-white, #f7f7f7); - border: 1px solid #e0e0e0; - border-radius: 15px; /* 둥근 태그 모양 */ - padding: 0.2em 0.8em; - margin-right: 0.5em; - font-size: 0.9em; - color: var(--font-color_default, #474747); -} - -/* 읽기 모드에서는 커서 모양을 기본으로 변경 */ -.write_option.controlbox-category:not(.btn-example), -.write_option.controlbox-hashtag:not(.btn-example) { - cursor: default; -} - -/* [추가] 태그/내용을 감싸는 래퍼 (줄바꿈 담당) */ -.tag-content-wrapper { - display: flex; /* 내부 아이템(태그 등)을 가로로 배치 */ - flex-wrap: wrap; /* 태그가 많으면 자동 줄바꿈 (기본값) */ - gap: 4px 6px; /* 태그 사이의 간격 (세로 4px, 가로 6px) */ - line-height: 1.4; /* 내용 줄간격 */ - width: 100%; /* 부모 박스(write_option) 너비를 채움 */ -} - - -/* --- 컨트롤 박스 공통 스타일 (카테고리, 해시태그, 위치) --- */ -/* [수정] 3개 박스에 공통 스타일을 적용합니다. */ -.write_option.controlbox-category, -.write_option.controlbox-hashtag, -.write_option.controlbox-location { - /* 모든 박스는 .write_option의 flex: 1 1 0% 규칙을 따릅니다. */ - box-sizing: border-box; - min-height: 50px; /* 모든 박스의 최소 높이 통일 */ -} - -/* --- 컨트롤 박스 내부 태그 아이템 공통 스타일 (팝업 편집용) --- */ -/* (카테고리와 해시태그 박스 내부의 태그 아이템만 이 스타일을 공유) */ -.write_option.controlbox-location .tag-item, -.write_option.controlbox-category .tag-item, -.write_option.controlbox-hashtag .tag-item { - background-color: var(--highlight-bg-color, #e9e9e9); - color: var(--highlight-text-color, #333); - border-radius: 5px; - padding: 3px 8px; - font-size: 0.9em; - white-space: normal; - margin-right: 0; /* .tag-content-wrapper의 gap이 간격을 제어하므로 마진 제거 */ -} - -/* --- 컨트롤 박스: 카테고리 (개별 내부 스타일) --- */ -.write_option.controlbox-category .tag-title { - font-weight: bold; - color: #555; - /* margin-right: 8px; <-- 세로 레이아웃으로 변경되어 불필요 */ - white-space: nowrap; -} - -/* --- 컨트롤 박스: 해시태그 (개별 내부 스타일) --- */ -.write_option.controlbox-hashtag { - /* 태그 사이의 내부 간격 (래퍼가 대신 처리) */ - /* gap: 8px 10px; */ -} - -.write_option.controlbox-hashtag .tag-title { - font-weight: bold; - color: #555; - margin-right: 0px; - white-space: nowrap; -} - -/* --- 컨트롤 박스: 콘텐츠 래퍼 동작 정의 --- */ - -/* 해시태그는 .tag-content-wrapper의 기본값(flex-wrap: wrap)을 그대로 사용합니다. */ - -/* [추가/그룹화] 카테고리와 위치 박스는 내용이 줄바꿈되지 않도록 공통 처리합니다. */ -/*.write_option.controlbox-category .tag-content-wrapper,*/ -/*.write_option.controlbox-location .tag-content-wrapper {*/ -/* white-space: nowrap; !* 내용이 길어도 한 줄로 표시 (줄바꿈 방지) *!*/ -/* overflow: hidden;*/ -/* text-overflow: ellipsis;*/ -/*}*/ - - -/* 추가: 컨트롤 박스 자체의 레이아웃 (이전 CSS에서 남음. 현재 구조에서는 영향 없음) */ -#main_container .write_option { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 20px; -} - -/* === POPUP TAG LIST STYLES (신규 추가) === */ -.pop_conts .tag-list { - display: flex; /* 태그들을 가로로 나열 */ - flex-wrap: wrap; /* 공간이 부족하면 다음 줄로 자동 줄바꿈 */ - gap: 8px; /* 태그 아이템 사이의 간격을 8px로 지정 */ - margin-bottom: 1.5em; /* 태그 목록과 하단 입력창 사이의 간격 */ - padding-bottom: 1.5em; /* 태그 목록 하단 패딩 */ - border-bottom: 1px solid #e0e0e0; /* 입력창과 목록을 구분하는 선 */ -} - -.pop_conts .tag-item { - display: inline-block; - background-color: var(--almost-white, #f7f7f7); - border: 1px solid #e0e0e0; - border-radius: 15px; /* 둥근 알약 모양 */ - padding: 0.3em 0.9em; /* 내부 여백 */ - font-size: 0.9em; - cursor: pointer; /* 클릭 가능하도록 커서 변경 */ - transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; -} - -.pop_conts .tag-item:hover { - background-color: #e0e0e0; /* 호버 시 배경색 변경 */ - border-color: #ccc; -} - -/* === (신규 추가) POPUP STAGING AREA STYLES === */ -.pop_conts .tag-list-label { - font-weight: 600; - font-size: 0.9em; - color: #777; - margin-top: 1.2em; /* 각 섹션 상단 여백 */ - margin-bottom: 0.5em; - border-bottom: 1px solid #eee; - padding-bottom: 5px; -} - -/* 선택된 항목을 담는 스테이징 영역 스타일 */ -.pop_conts .staging-area { - min-height: 40px; - padding: 0.5em; - background: #fdfdfd; - border: 1px dashed #ccc; /* 점선 테두리로 구분 */ - border-radius: 5px; -} - -/* 스테이징 영역 내부의 태그에서 'X' (삭제) 버튼 스타일 */ -.staging-area .tag-item .remove-tag { - font-family: "Courier New", monospace; /* 'X' 버튼 글꼴 */ - font-weight: bold; - color: #cc0000; /* 어두운 빨간색 */ - margin-left: 8px; - cursor: pointer; /* 클릭 가능하도록 */ - display: inline-block; - padding: 0 2px; -} -.staging-area .tag-item .remove-tag:hover { - color: #ff0000; /* 밝은 빨간색 */ - background-color: #eee; -} - -/* 카테고리 스테이징 영역은 flex가 아니므로, 내부 태그 마진을 별도 지정 */ -#selected-category-area .tag-item { - margin: 0; -} -#selected-category-area i, #selected-hashtags-area i { - color: #999; -} - -/* === 대댓글 기능 CSS (신규 추가) === */ - -/* 1. 대댓글 목록 컨테이너 (들여쓰기) */ -.reply-list { - margin-left: 40px; - padding-left: 15px; - border-left: 2px solid #eee; -} - -/* 2. 댓글 헤더 (작성자/날짜/답글 버튼) */ -.comment-header { - border-bottom: 1px dotted #ddd; - padding-bottom: 5px; - margin-bottom: 10px; - /* Flexbox를 사용해 요소를 양쪽으로 정렬 */ - display: flex; - justify-content: space-between; - align-items: center; -} - -.comment-author-info { - font-size: 0.9em; -} - -.comment-author-info strong { - margin-right: 8px; -} - -.comment-author-info .comment-date { - font-size: 0.9em; - color: #888; -} - -/* 3. 답글 달기 버튼 */ -.btn-reply { - font-size: 0.8em; - padding: 3px 8px; - cursor: pointer; - border: 1px solid #ccc; - background: #f9f9f9; - border-radius: 4px; - color: #555; -} -.btn-reply:hover { - background: #eee; -} - -/* 4. 답글 상태 표시줄 (숨겨진 UI) */ -#reply-status-bar { - display: none; /* JS로 제어 */ - background: #f0f8ff; - border: 1px solid #bde0ff; - padding: 8px 12px; - margin-bottom: 10px; - border-radius: 4px; - display: flex; - justify-content: space-between; - align-items: center; -} -#reply-status-text { - font-size: 0.9em; - color: #333; - font-weight: 500; -} -#btn-cancel-reply { - background: none; - border: none; - color: #E63946; /* 빨간색 계열 */ - cursor: pointer; - font-size: 0.85em; - font-weight: bold; -} -@media screen and (max-width: 480px) { - .vote-controls { - display: flex; /* Flexbox 컨테이너로 변경 */ - gap: 1em; /* 버튼 사이 간격 (기존 margin-left 대체) */ - } - - .vote-controls .button { - width: auto; /* main.css의 width: 100% 덮어쓰기 */ - display: inline-block; /* main.css의 display: block 덮어쓰기 */ - flex: 1 1 0; /* 1:1 비율로 공간을 나눠 갖도록 설정 */ - margin-left: 0 !important; /* 혹시 모를 인라인 스타일 제거 */ - } -} \ No newline at end of file diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index f87b19a..4b11e8d 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -3424,15 +3424,6 @@ button.small, padding: 5px; } -/*.login_form button {*/ -/* width: 100%;*/ -/* padding: 10px;*/ -/* background-color: #4CAF50;*/ -/* color: white;*/ -/* border: none;*/ -/* cursor: pointer;*/ -/*}*/ - .login_close { position: absolute; top: 10px; @@ -3441,3 +3432,654 @@ button.small, cursor: pointer; } +/* + * --- POPUP AND DIM LAYER STYLES --- + * Reworked to use variables and styles from main.css for a consistent look. +*/ +.pop_layer { + display: none; + position: fixed; /* Use fixed for proper viewport centering */ + top: 50%; + left: 50%; + transform: translate(-50%, -50%); /* Modern centering method */ + width: 450px; + max-width: 90%; /* Ensure it's responsive on small screens */ + height: auto; + background-color: var(--pure-white, #fff); /* Use theme's white variable */ + border: 1px solid #e0e0e0; /* Use theme's standard border color */ + box-shadow: 0 0 15px rgba(0, 0, 0, 0.15); /* Add a subtle shadow */ + z-index: 1001; /* Ensure it's above the dim layer */ + border-radius: 5px; /* Use theme's standard border-radius */ +} + +.pop_layer .pop_container { + padding: 2em; +} + +.pop_layer .pop_conts h2 { + font-size: 1.75em; /* Match theme's h2 style */ + margin-bottom: 1em; + text-align: center; +} + +.pop_layer p.ctxt { + color: var(--font-color_default, #474747); /* Use theme's text color */ + line-height: 1.65em; +} + +.pop_layer .btn_r { + width: 100%; + margin-top: 1.5em; + padding-top: 1em; + border-top: 1px solid #e0e0e0; /* Use theme's divider color */ + text-align: right; +} + +/* Style the close button to match the theme's alternate button style */ +a.btn_layerClose { + display: inline-block; + padding: 0 1.5em; + line-height: 2.75em; + text-decoration: none; + font-weight: 600; + border-radius: 5px; + cursor: pointer; + background-color: var(--button-alt-default, #555); + color: var(--pure-white, #fff); + transition: background-color 0.2s ease-in-out; +} + +a.btn_layerClose:hover { + background-color: var(--button-alt-hover, #626262); + color: var(--pure-white, #fff) !important; /* Ensure hover color override */ +} + + +.dim_layer { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; + background-color: rgba(0, 0, 0, 0.4); /* Slightly softer dim */ +} + +/* * --- LOGIN FORM STYLES --- + * Adapted to use the default form input styles from main.css. +*/ +#loginFormElement input[type="text"], +#loginFormElement input[type="password"] { + margin-bottom: 1em; /* Add spacing between fields */ +} + +#loginFormElement button { + margin-top: 1em; + width: 100%; +} + +/* Custom Checkbox Styling */ +#loginFormElement span { + vertical-align: middle; + margin-left: 0.5em; +} + +#rememberMe { + vertical-align: middle; + width: 22px; + height: 22px; + appearance: none; + -webkit-appearance: none; + border: 1px solid #e0e0e0; + border-radius: 5px; + background-color: var(--pure-white, #fff); + cursor: pointer; + position: relative; + top: -2px; +} + +#rememberMe:checked { + background-color: var(--point-color, #FFA500); + border-color: var(--point-color, #FFA500); +} + +#rememberMe:checked::after { + content: ''; + position: absolute; + top: 2px; + left: 7px; + width: 6px; + height: 12px; + border: solid var(--pure-white, #fff); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +/* * --- BLOG POST & EDITOR STYLES --- +*/ + +/* Control box below the editor in viewer.html */ +.write_controllbox { + margin-top: 2em; + padding: 0; + display: flex; + flex-direction: row; /* [수정] 아이템을 가로(row)로 정렬합니다. */ + align-items: stretch; /* [수정] 모든 박스가 동일한 높이를 갖도록 stretch로 변경 */ + list-style: none; + gap: 15px; /* Use gap for spacing */ +} + +.write_option { + display: flex; + flex-direction: column; /* [수정] 자식(제목, 내용)을 세로로 쌓습니다. */ + justify-content: flex-start; /* 내용을 상단부터 표시합니다. */ + align-items: flex-start; /* 모든 내용을 왼쪽 정렬합니다. */ + + flex: 1 1 0%; /* [핵심] 1:1:1 비율로 동일한 너비를 갖도록 설정 */ + min-width: 0; /* [추가] flex item이 내용 래핑을 위해 축소될 수 있도록 허용 */ + + padding: 0.75em; + margin: 0; + background: var(--pure-white, #fff); + border: solid 1px #e0e0e0; + border-radius: 5px; + color: inherit; + min-height: 48px; +} + + +/* * --- QUILL EDITOR THEME OVERRIDE --- + * Modified to blend with the Arcana theme's light background. +*/ + +/* Define custom fonts for Quill to match the site */ +.ql-font-source-sans-pro { font-family: 'Source Sans Pro', sans-serif; } +/* Add any other fonts you've whitelisted in common.js */ + +.ql-toolbar.ql-snow { + background: var(--almost-white, #f7f7f7); /* Use theme's light gray */ + border: 1px solid #e0e0e0; + border-bottom: none; /* Connect toolbar to editor visually */ + border-radius: 5px 5px 0 0; + padding: 12px 8px; +} + +.ql-container.ql-snow { + min-width: unset; + background: var(--pure-white, #fff); + border: 1px solid #e0e0e0; + border-radius: 0 0 5px 5px; + color: var(--font-color_default, #474747); +} +/* * --- QUILL TOOLBAR SEPARATOR (구분선) --- + * 기본 'margin-right: 15px' 대신 패딩과 보더로 구분선을 추가합니다. +*/ + + +.ql-toolbar.ql-snow .ql-formats { + margin-right: unset; +} + +.ql-toolbar.ql-snow .ql-formats { + min-width: unset; + margin-right: 8px; /* 기본 마진 감소 */ + padding-right: 8px; /* 보더 오른쪽에 패딩 추가 */ + border-right: 1px solid #e0e0e0; /* 테마의 보더 색상으로 구분선 추가 */ +} + +/* 툴바의 마지막 그룹에는 구분선과 불필요한 여백을 제거합니다. */ +.ql-toolbar.ql-snow .ql-formats:last-child { + min-width: unset; + margin-right: 0; + padding-right: 0; + border-right: none; +} + +/* Ensure editor content uses the theme's default font and size */ +.ql-editor { + font-family: 'Source Sans Pro', sans-serif; + font-size: 1rem; /* Base size */ + line-height: 1.65em; +} + + +.ql-snow.ql-toolbar button, .ql-snow .ql-toolbar button { + background: none; + border: none; + cursor: pointer; + display: inline-block; + float: left; + height: 24px; + padding: 3px 5px; + min-width: unset; +} + +.ql-snow .ql-picker-label { + cursor: pointer; + display: inline-block; + height: 100%; + padding-left: unset; + padding-right: unset; + position: relative; + width: 100%; +} +/* + * --- COMMENT SECTION --- + * Styled to fit the main theme. +*/ +.comment-section { + margin-top: 3em; + padding-top: 2em; + border-top: 1px solid #e0e0e0; +} + +#comment-form-container { + display: flex; + flex-direction: column; + margin-bottom: 2em; +} + +/* Inherits styles from main.css 'textarea' selector */ +#comment-input { + min-height: 100px; + margin-bottom: 1em; +} + +#comments-list .comment { + background: var(--almost-white, #f7f7f7); + border-radius: 5px; + padding: 1em 1.5em; + margin-bottom: 1em; + border: 1px solid #e0e0e0; +} + +.comment-header { + font-weight: 600; + color: var(--font-color_default, #474747); + margin-bottom: 0.5em; +} + +.comment-time { + font-size: 0.9em; + color: #999; + margin-left: 0.5em; +} + +.comment-content { + line-height: 1.65em; + white-space: pre-wrap; + word-wrap: break-word; +} + +/* 컨트롤 박스 내부 공통 제목 스타일 */ +.write_option .tag-title { + font-weight: 600; + margin-bottom: 0.35em; /* [수정] 제목과 내용 사이에 하단 여백을 줍니다. */ + color: #555; + font-size: 0.85em; /* [추가] 제목 라벨 폰트를 살짝 작게 */ + text-transform: uppercase; /* [추가] 라벨처럼 보이도록 영문 대문자 처리 */ + + /* 태그 제목(HASHTAGS)이 태그 아이템과 동일한 스타일을 공유할 경우 대비 (선택 사항) */ + display: block; + width: 100%; +} + +/* 컨트롤 박스 내부 공통 태그 아이템 스타일 (뷰어 모드용) */ +.write_option .tag-item { + display: inline-block; + background-color: var(--almost-white, #f7f7f7); + border: 1px solid #e0e0e0; + border-radius: 15px; /* 둥근 태그 모양 */ + padding: 0.2em 0.8em; + margin-right: 0.5em; + font-size: 0.9em; + color: var(--font-color_default, #474747); +} + +/* 읽기 모드에서는 커서 모양을 기본으로 변경 */ +.write_option.controlbox-category:not(.btn-example), +.write_option.controlbox-hashtag:not(.btn-example) { + cursor: default; +} + +/* [추가] 태그/내용을 감싸는 래퍼 (줄바꿈 담당) */ +.tag-content-wrapper { + display: flex; /* 내부 아이템(태그 등)을 가로로 배치 */ + flex-wrap: wrap; /* 태그가 많으면 자동 줄바꿈 (기본값) */ + gap: 4px 6px; /* 태그 사이의 간격 (세로 4px, 가로 6px) */ + line-height: 1.4; /* 내용 줄간격 */ + width: 100%; /* 부모 박스(write_option) 너비를 채움 */ +} + + +/* --- 컨트롤 박스 공통 스타일 (카테고리, 해시태그, 위치) --- */ +/* [수정] 3개 박스에 공통 스타일을 적용합니다. */ +.write_option.controlbox-category, +.write_option.controlbox-hashtag, +.write_option.controlbox-location { + /* 모든 박스는 .write_option의 flex: 1 1 0% 규칙을 따릅니다. */ + box-sizing: border-box; + min-height: 50px; /* 모든 박스의 최소 높이 통일 */ +} + +/* --- 컨트롤 박스 내부 태그 아이템 공통 스타일 (팝업 편집용) --- */ +/* (카테고리와 해시태그 박스 내부의 태그 아이템만 이 스타일을 공유) */ +.write_option.controlbox-location .tag-item, +.write_option.controlbox-category .tag-item, +.write_option.controlbox-hashtag .tag-item { + background-color: var(--highlight-bg-color, #e9e9e9); + color: var(--highlight-text-color, #333); + border-radius: 5px; + padding: 3px 8px; + font-size: 0.9em; + white-space: normal; + margin-right: 0; /* .tag-content-wrapper의 gap이 간격을 제어하므로 마진 제거 */ +} + +/* --- 컨트롤 박스: 카테고리 (개별 내부 스타일) --- */ +.write_option.controlbox-category .tag-title { + font-weight: bold; + color: #555; + /* margin-right: 8px; <-- 세로 레이아웃으로 변경되어 불필요 */ + white-space: nowrap; +} + +/* --- 컨트롤 박스: 해시태그 (개별 내부 스타일) --- */ +.write_option.controlbox-hashtag { + /* 태그 사이의 내부 간격 (래퍼가 대신 처리) */ + /* gap: 8px 10px; */ +} + +.write_option.controlbox-hashtag .tag-title { + font-weight: bold; + color: #555; + margin-right: 0px; + white-space: nowrap; +} + +/* --- 컨트롤 박스: 콘텐츠 래퍼 동작 정의 --- */ + +/* 해시태그는 .tag-content-wrapper의 기본값(flex-wrap: wrap)을 그대로 사용합니다. */ + +/* [추가/그룹화] 카테고리와 위치 박스는 내용이 줄바꿈되지 않도록 공통 처리합니다. */ +/*.write_option.controlbox-category .tag-content-wrapper,*/ +/*.write_option.controlbox-location .tag-content-wrapper {*/ +/* white-space: nowrap; !* 내용이 길어도 한 줄로 표시 (줄바꿈 방지) *!*/ +/* overflow: hidden;*/ +/* text-overflow: ellipsis;*/ +/*}*/ + + +/* 추가: 컨트롤 박스 자체의 레이아웃 (이전 CSS에서 남음. 현재 구조에서는 영향 없음) */ +#main_container .write_option { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; +} + +/* === POPUP TAG LIST STYLES (신규 추가) === */ +.pop_conts .tag-list { + display: flex; /* 태그들을 가로로 나열 */ + flex-wrap: wrap; /* 공간이 부족하면 다음 줄로 자동 줄바꿈 */ + gap: 8px; /* 태그 아이템 사이의 간격을 8px로 지정 */ + margin-bottom: 1.5em; /* 태그 목록과 하단 입력창 사이의 간격 */ + padding-bottom: 1.5em; /* 태그 목록 하단 패딩 */ + border-bottom: 1px solid #e0e0e0; /* 입력창과 목록을 구분하는 선 */ +} + +.pop_conts .tag-item { + display: inline-block; + background-color: var(--almost-white, #f7f7f7); + border: 1px solid #e0e0e0; + border-radius: 15px; /* 둥근 알약 모양 */ + padding: 0.3em 0.9em; /* 내부 여백 */ + font-size: 0.9em; + cursor: pointer; /* 클릭 가능하도록 커서 변경 */ + transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; +} + +.pop_conts .tag-item:hover { + background-color: #e0e0e0; /* 호버 시 배경색 변경 */ + border-color: #ccc; +} + +/* === (신규 추가) POPUP STAGING AREA STYLES === */ +.pop_conts .tag-list-label { + font-weight: 600; + font-size: 0.9em; + color: #777; + margin-top: 1.2em; /* 각 섹션 상단 여백 */ + margin-bottom: 0.5em; + border-bottom: 1px solid #eee; + padding-bottom: 5px; +} + +/* 선택된 항목을 담는 스테이징 영역 스타일 */ +.pop_conts .staging-area { + min-height: 40px; + padding: 0.5em; + background: #fdfdfd; + border: 1px dashed #ccc; /* 점선 테두리로 구분 */ + border-radius: 5px; +} + +/* 스테이징 영역 내부의 태그에서 'X' (삭제) 버튼 스타일 */ +.staging-area .tag-item .remove-tag { + font-family: "Courier New", monospace; /* 'X' 버튼 글꼴 */ + font-weight: bold; + color: #cc0000; /* 어두운 빨간색 */ + margin-left: 8px; + cursor: pointer; /* 클릭 가능하도록 */ + display: inline-block; + padding: 0 2px; +} +.staging-area .tag-item .remove-tag:hover { + color: #ff0000; /* 밝은 빨간색 */ + background-color: #eee; +} + +/* 카테고리 스테이징 영역은 flex가 아니므로, 내부 태그 마진을 별도 지정 */ +#selected-category-area .tag-item { + margin: 0; +} +#selected-category-area i, #selected-hashtags-area i { + color: #999; +} + +/* === 대댓글 기능 CSS (신규 추가) === */ + +/* 1. 대댓글 목록 컨테이너 (들여쓰기) */ +.reply-list { + margin-left: 40px; + padding-left: 15px; + border-left: 2px solid #eee; +} + +/* 2. 댓글 헤더 (작성자/날짜/답글 버튼) */ +.comment-header { + border-bottom: 1px dotted #ddd; + padding-bottom: 5px; + margin-bottom: 10px; + /* Flexbox를 사용해 요소를 양쪽으로 정렬 */ + display: flex; + justify-content: space-between; + align-items: center; +} + +.comment-author-info { + font-size: 0.9em; +} + +.comment-author-info strong { + margin-right: 8px; +} + +.comment-author-info .comment-date { + font-size: 0.9em; + color: #888; +} + +/* 3. 답글 달기 버튼 */ +.btn-reply { + font-size: 0.8em; + padding: 3px 8px; + cursor: pointer; + border: 1px solid #ccc; + background: #f9f9f9; + border-radius: 4px; + color: #555; +} +.btn-reply:hover { + background: #eee; +} + +/* 4. 답글 상태 표시줄 (숨겨진 UI) */ +#reply-status-bar { + display: none; /* JS로 제어 */ + background: #f0f8ff; + border: 1px solid #bde0ff; + padding: 8px 12px; + margin-bottom: 10px; + border-radius: 4px; + display: flex; + justify-content: space-between; + align-items: center; +} +#reply-status-text { + font-size: 0.9em; + color: #333; + font-weight: 500; +} +#btn-cancel-reply { + background: none; + border: none; + color: #E63946; /* 빨간색 계열 */ + cursor: pointer; + font-size: 0.85em; + font-weight: bold; +} +@media screen and (max-width: 480px) { + .vote-controls { + display: flex; /* Flexbox 컨테이너로 변경 */ + gap: 1em; /* 버튼 사이 간격 (기존 margin-left 대체) */ + } + + .vote-controls .button { + width: auto; /* main.css의 width: 100% 덮어쓰기 */ + display: inline-block; /* main.css의 display: block 덮어쓰기 */ + flex: 1 1 0; /* 1:1 비율로 공간을 나눠 갖도록 설정 */ + margin-left: 0 !important; /* 혹시 모를 인라인 스타일 제거 */ + } +} + +/*private*/ + + /* 개별 위치 로그 아이템 스타일 */ + .location-item { + background-color: #fcfcfc; + border: 1px solid #e0e0e0; + border-radius: 6px; + margin-bottom: 20px; + padding: 15px 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + transition: all 0.2s ease-in-out; + } + +.location-item:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +/* 헤더 (날짜 및 경과 시간) */ +.location-header { + display: flex; /* 가로로 배치 */ + justify-content: space-between; /* 양 끝 정렬 */ + align-items: center; + padding-bottom: 10px; + border-bottom: 1px solid #eee; + margin-bottom: 10px; +} + +.location-time { + font-weight: bold; + color: #555; + font-size: 1.1em; +} + +.location-between { + font-size: 0.9em; + color: #888; + background-color: #eef; + padding: 3px 8px; + border-radius: 4px; +} + +/* 본문 (주소 및 좌표) */ +.location-body { + font-size: 0.95em; +} + +.location-address { + margin-top: 0; + margin-bottom: 8px; + color: #444; + word-break: break-all; /* 긴 주소 자동 줄바꿈 */ +} + +.location-coords { + margin: 0; + color: #666; + font-style: italic; + display: flex; /* 위도, 경도, 국가명 가로 배치 */ + gap: 15px; /* 각 요소 사이 간격 */ + flex-wrap: wrap; /* 화면 좁아지면 줄바꿈 */ +} + +.location-coords b { + color: #333; + font-weight: bold; +} + +.location-country { + color: #777; + font-size: 0.9em; +} + +/* 페이지네이션 스타일 */ +.pagination { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #eee; +} + +.pagination a, .pagination span { + display: inline-block; + padding: 8px 15px; + margin: 0 4px; + border: 1px solid #ddd; + border-radius: 5px; + text-decoration: none; + color: #007bff; + background-color: #f8f9fa; + transition: all 0.2s ease; +} + +.pagination a:hover { + background-color: #e9ecef; + border-color: #007bff; + color: #0056b3; +} + +.pagination span { /* 현재 페이지 또는 비활성화된 버튼 */ + color: #6c757d; + background-color: #e2e6ea; + border-color: #e2e6ea; + cursor: default; +} + +/* 기존 CSS에서 불필요한 부분 제거 또는 통합 */ +.where_item { /* 이전 코드의 클래스. 새 클래스로 대체되었으므로 필요 없으면 제거 */ + /* display: none; */ +} \ No newline at end of file diff --git a/src/main/resources/static/css/private.css b/src/main/resources/static/css/private.css deleted file mode 100644 index 012d9dd..0000000 --- a/src/main/resources/static/css/private.css +++ /dev/null @@ -1,128 +0,0 @@ -/* 기본 레이아웃 및 폰트 */ -body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background-color: #f4f7f6; - color: #333; - line-height: 1.6; -} - -#main_layer { - max-width: 900px; - margin: 30px auto; - background-color: #ffffff; - padding: 25px; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); -} - -.layer { - padding: 10px 0; -} - -/* 개별 위치 로그 아이템 스타일 */ -.location-item { - background-color: #fcfcfc; - border: 1px solid #e0e0e0; - border-radius: 6px; - margin-bottom: 20px; - padding: 15px 20px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); - transition: all 0.2s ease-in-out; -} - -.location-item:hover { - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - transform: translateY(-2px); -} - -/* 헤더 (날짜 및 경과 시간) */ -.location-header { - display: flex; /* 가로로 배치 */ - justify-content: space-between; /* 양 끝 정렬 */ - align-items: center; - padding-bottom: 10px; - border-bottom: 1px solid #eee; - margin-bottom: 10px; -} - -.location-time { - font-weight: bold; - color: #555; - font-size: 1.1em; -} - -.location-between { - font-size: 0.9em; - color: #888; - background-color: #eef; - padding: 3px 8px; - border-radius: 4px; -} - -/* 본문 (주소 및 좌표) */ -.location-body { - font-size: 0.95em; -} - -.location-address { - margin-top: 0; - margin-bottom: 8px; - color: #444; - word-break: break-all; /* 긴 주소 자동 줄바꿈 */ -} - -.location-coords { - margin: 0; - color: #666; - font-style: italic; - display: flex; /* 위도, 경도, 국가명 가로 배치 */ - gap: 15px; /* 각 요소 사이 간격 */ - flex-wrap: wrap; /* 화면 좁아지면 줄바꿈 */ -} - -.location-coords b { - color: #333; - font-weight: bold; -} - -.location-country { - color: #777; - font-size: 0.9em; -} - -/* 페이지네이션 스타일 */ -.pagination { - margin-top: 30px; - padding-top: 20px; - border-top: 1px solid #eee; -} - -.pagination a, .pagination span { - display: inline-block; - padding: 8px 15px; - margin: 0 4px; - border: 1px solid #ddd; - border-radius: 5px; - text-decoration: none; - color: #007bff; - background-color: #f8f9fa; - transition: all 0.2s ease; -} - -.pagination a:hover { - background-color: #e9ecef; - border-color: #007bff; - color: #0056b3; -} - -.pagination span { /* 현재 페이지 또는 비활성화된 버튼 */ - color: #6c757d; - background-color: #e2e6ea; - border-color: #e2e6ea; - cursor: default; -} - -/* 기존 CSS에서 불필요한 부분 제거 또는 통합 */ -.where_item { /* 이전 코드의 클래스. 새 클래스로 대체되었으므로 필요 없으면 제거 */ - /* display: none; */ -} \ No newline at end of file diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index 21a3e74..14b58b0 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -52,8 +52,63 @@ var baseData = { */ window.addEventListener('DOMContentLoaded', () => { + const urlParams = new URLSearchParams(window.location.search); + const action = urlParams.get('action'); + + if (action === 'login') { + const loginPopup = document.getElementById('loginPopup'); + if (loginPopup) { + // openPopup 함수는 특정 버튼(element)을 필요로 하므로, + // 팝업 div 자체에 임시로 'to' 속성을 부여하여 재사용합니다. + loginPopup.setAttribute('to', '#loginPopup'); + openPopup(loginPopup); + } + } else if (action === 'signup') { + const signupPopup = document.getElementById('signupPopup'); + if (signupPopup) { + signupPopup.setAttribute('to', '#signupPopup'); + openPopup(signupPopup); + } + } + + const openSignupBtn = document.getElementById('openSignupBtnFromLogin'); + if (openSignupBtn) { + openSignupBtn.addEventListener('click', () => { + // 1. 현재 열려있는 로그인 팝업을 닫습니다. + closePopup(); + + // 2. 회원가입 팝업을 찾아서 엽니다. + const signupPopup = document.getElementById('signupPopup'); + if (signupPopup) { + // openPopup 함수를 재사용하기 위해 'to' 속성을 설정합니다. + signupPopup.setAttribute('to', '#signupPopup'); + openPopup(signupPopup); + } + }); + } + console.log("DOM Loaded: Attaching Vanilla JS event listeners."); + const logoutButton = document.querySelector('a[href="javascript:logout()"]'); + const isLoggedIn = !!logoutButton; // true 또는 false + + const commentForm = document.querySelector('.comment-form-wrapper'); + if (commentForm) { + if (!isLoggedIn) { + // 비로그인 상태면, 입력 필드와 버튼을 비활성화합니다. + const commentInput = commentForm.querySelector('#comment-input'); + const commentSubmitBtn = commentForm.querySelector('#submit-comment'); + + if(commentInput) { + commentInput.disabled = true; + commentInput.placeholder = '댓글을 작성하려면 로그인이 필요합니다.'; + } + if(commentSubmitBtn) { + commentSubmitBtn.disabled = true; + } + } + } + // --- 1. 사이드바 목록 가져오기 (이 기능은 이미 바닐라 JS였습니다) --- if (document.querySelector(".rank_of_view")) { fetchRankOfViews(); @@ -107,8 +162,13 @@ window.addEventListener('DOMContentLoaded', () => { const commentSubmitBtn = document.getElementById('submit-comment'); if (commentSubmitBtn) { commentSubmitBtn.addEventListener('click', (e) => { - e.preventDefault(); // 기본 버튼 동작 방지 - submitComment(); // 댓글 제출 함수 호출 + e.preventDefault(); + // isLoggedIn 변수를 사용하여 추가적인 클라이언트 측 방어 + if (!isLoggedIn) { + alert('로그인이 필요합니다.'); + return; + } + submitComment(); }); } @@ -311,7 +371,9 @@ async function uploadImage(blob) { const formData = new FormData(); formData.append('file', blob); let uploadUrl = getMainPath() + "/blog/post/imageUpload.bjx"; - let imageUrlBase = getMainPath() + '/blog/post/images/'; + // let imageUrlBase = getMainPath() + '/blog/post/images/'; +// [수정] 이미지 URL의 기본 경로를 새로운 API 경로로 변경합니다. + let imageUrlBase = getMainPath() + '/api/images/'; // '/blog/post/images/' -> '/api/images/' try { // CSRF 토큰을 태그에서 직접 읽어옵니다. (includes.html에 정의되어 있음) @@ -1331,3 +1393,5 @@ async function fetchRanks(gameType, contextId = null) { } return response.json(); } + + diff --git a/src/main/resources/static/js/main.js b/src/main/resources/static/js/main.js deleted file mode 100644 index 3ddffb9..0000000 --- a/src/main/resources/static/js/main.js +++ /dev/null @@ -1,67 +0,0 @@ -/* - Arcana by HTML5 UP - html5up.net | @ajlkn - Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) -*/ - -(function($) { - - var $window = $(window), - $body = $('body'); - - // Breakpoints. - breakpoints({ - wide: [ '1281px', '1680px' ], - normal: [ '981px', '1280px' ], - narrow: [ '841px', '980px' ], - narrower: [ '737px', '840px' ], - mobile: [ '481px', '736px' ], - mobilep: [ null, '480px' ] - }); - - // Play initial animations on page load. - $window.on('load', function() { - window.setTimeout(function() { - $body.removeClass('is-preload'); - }, 100); - }); - - // Dropdowns. - $('#nav > ul').dropotron({ - offsetY: -15, - hoverDelay: 0, - alignment: 'center' - }); - - // Nav. - - // Bar. - $( - '
' + - '' + - '' + $('#logo').html() + '' + - '
' - ) - .appendTo($body); - - // Panel. - $( - '' - ) - .appendTo($body) - .panel({ - delay: 500, - hideOnClick: true, - hideOnSwipe: true, - resetScroll: true, - resetForms: true, - side: 'left', - target: $body, - visibleClass: 'navPanel-visible' - }); - -})(jQuery); \ No newline at end of file diff --git a/src/main/resources/static/js/sha256.js b/src/main/resources/static/js/sha256.js index b7d0fe6..adae800 100644 --- a/src/main/resources/static/js/sha256.js +++ b/src/main/resources/static/js/sha256.js @@ -1,517 +1,517 @@ -(function () { - 'use strict'; - - var ERROR = 'input is invalid type'; - var WINDOW = typeof window === 'object'; - var root = WINDOW ? window : {}; - if (root.JS_SHA256_NO_WINDOW) { - WINDOW = false; - } - var WEB_WORKER = !WINDOW && typeof self === 'object'; - var NODE_JS = !root.JS_SHA256_NO_NODE_JS && typeof process === 'object' && process.versions && process.versions.node; - if (NODE_JS) { - root = global; - } else if (WEB_WORKER) { - root = self; - } - var COMMON_JS = !root.JS_SHA256_NO_COMMON_JS && typeof module === 'object' && module.exports; - var AMD = typeof define === 'function' && define.amd; - var ARRAY_BUFFER = !root.JS_SHA256_NO_ARRAY_BUFFER && typeof ArrayBuffer !== 'undefined'; - var HEX_CHARS = '0123456789abcdef'.split(''); - var EXTRA = [-2147483648, 8388608, 32768, 128]; - var SHIFT = [24, 16, 8, 0]; - var K = [ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 - ]; - var OUTPUT_TYPES = ['hex', 'array', 'digest', 'arrayBuffer']; - - var blocks = []; - - if (root.JS_SHA256_NO_NODE_JS || !Array.isArray) { - Array.isArray = function (obj) { - return Object.prototype.toString.call(obj) === '[object Array]'; - }; - } - - if (ARRAY_BUFFER && (root.JS_SHA256_NO_ARRAY_BUFFER_IS_VIEW || !ArrayBuffer.isView)) { - ArrayBuffer.isView = function (obj) { - return typeof obj === 'object' && obj.buffer && obj.buffer.constructor === ArrayBuffer; - }; - } - - var createOutputMethod = function (outputType, is224) { - return function (message) { - return new Sha256(is224, true).update(message)[outputType](); - }; - }; - - var createMethod = function (is224) { - var method = createOutputMethod('hex', is224); - if (NODE_JS) { - method = nodeWrap(method, is224); - } - method.create = function () { - return new Sha256(is224); - }; - method.update = function (message) { - return method.create().update(message); - }; - for (var i = 0; i < OUTPUT_TYPES.length; ++i) { - var type = OUTPUT_TYPES[i]; - method[type] = createOutputMethod(type, is224); - } - return method; - }; - - var nodeWrap = function (method, is224) { - var crypto = require('crypto') - var Buffer = require('buffer').Buffer; - var algorithm = is224 ? 'sha224' : 'sha256'; - var bufferFrom; - if (Buffer.from && !root.JS_SHA256_NO_BUFFER_FROM) { - bufferFrom = Buffer.from; - } else { - bufferFrom = function (message) { - return new Buffer(message); - }; - } - var nodeMethod = function (message) { - if (typeof message === 'string') { - return crypto.createHash(algorithm).update(message, 'utf8').digest('hex'); - } else { - if (message === null || message === undefined) { - throw new Error(ERROR); - } else if (message.constructor === ArrayBuffer) { - message = new Uint8Array(message); - } - } - if (Array.isArray(message) || ArrayBuffer.isView(message) || - message.constructor === Buffer) { - return crypto.createHash(algorithm).update(bufferFrom(message)).digest('hex'); - } else { - return method(message); - } - }; - return nodeMethod; - }; - - var createHmacOutputMethod = function (outputType, is224) { - return function (key, message) { - return new HmacSha256(key, is224, true).update(message)[outputType](); - }; - }; - - var createHmacMethod = function (is224) { - var method = createHmacOutputMethod('hex', is224); - method.create = function (key) { - return new HmacSha256(key, is224); - }; - method.update = function (key, message) { - return method.create(key).update(message); - }; - for (var i = 0; i < OUTPUT_TYPES.length; ++i) { - var type = OUTPUT_TYPES[i]; - method[type] = createHmacOutputMethod(type, is224); - } - return method; - }; - - function Sha256(is224, sharedMemory) { - if (sharedMemory) { - blocks[0] = blocks[16] = blocks[1] = blocks[2] = blocks[3] = - blocks[4] = blocks[5] = blocks[6] = blocks[7] = - blocks[8] = blocks[9] = blocks[10] = blocks[11] = - blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; - this.blocks = blocks; - } else { - this.blocks = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - } - - if (is224) { - this.h0 = 0xc1059ed8; - this.h1 = 0x367cd507; - this.h2 = 0x3070dd17; - this.h3 = 0xf70e5939; - this.h4 = 0xffc00b31; - this.h5 = 0x68581511; - this.h6 = 0x64f98fa7; - this.h7 = 0xbefa4fa4; - } else { // 256 - this.h0 = 0x6a09e667; - this.h1 = 0xbb67ae85; - this.h2 = 0x3c6ef372; - this.h3 = 0xa54ff53a; - this.h4 = 0x510e527f; - this.h5 = 0x9b05688c; - this.h6 = 0x1f83d9ab; - this.h7 = 0x5be0cd19; - } - - this.block = this.start = this.bytes = this.hBytes = 0; - this.finalized = this.hashed = false; - this.first = true; - this.is224 = is224; - } - - Sha256.prototype.update = function (message) { - if (this.finalized) { - return; - } - var notString, type = typeof message; - if (type !== 'string') { - if (type === 'object') { - if (message === null) { - throw new Error(ERROR); - } else if (ARRAY_BUFFER && message.constructor === ArrayBuffer) { - message = new Uint8Array(message); - } else if (!Array.isArray(message)) { - if (!ARRAY_BUFFER || !ArrayBuffer.isView(message)) { - throw new Error(ERROR); - } - } - } else { - throw new Error(ERROR); - } - notString = true; - } - var code, index = 0, i, length = message.length, blocks = this.blocks; - while (index < length) { - if (this.hashed) { - this.hashed = false; - blocks[0] = this.block; - this.block = blocks[16] = blocks[1] = blocks[2] = blocks[3] = - blocks[4] = blocks[5] = blocks[6] = blocks[7] = - blocks[8] = blocks[9] = blocks[10] = blocks[11] = - blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; - } - - if (notString) { - for (i = this.start; index < length && i < 64; ++index) { - blocks[i >>> 2] |= message[index] << SHIFT[i++ & 3]; - } - } else { - for (i = this.start; index < length && i < 64; ++index) { - code = message.charCodeAt(index); - if (code < 0x80) { - blocks[i >>> 2] |= code << SHIFT[i++ & 3]; - } else if (code < 0x800) { - blocks[i >>> 2] |= (0xc0 | (code >>> 6)) << SHIFT[i++ & 3]; - blocks[i >>> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; - } else if (code < 0xd800 || code >= 0xe000) { - blocks[i >>> 2] |= (0xe0 | (code >>> 12)) << SHIFT[i++ & 3]; - blocks[i >>> 2] |= (0x80 | ((code >>> 6) & 0x3f)) << SHIFT[i++ & 3]; - blocks[i >>> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; - } else { - code = 0x10000 + (((code & 0x3ff) << 10) | (message.charCodeAt(++index) & 0x3ff)); - blocks[i >>> 2] |= (0xf0 | (code >>> 18)) << SHIFT[i++ & 3]; - blocks[i >>> 2] |= (0x80 | ((code >>> 12) & 0x3f)) << SHIFT[i++ & 3]; - blocks[i >>> 2] |= (0x80 | ((code >>> 6) & 0x3f)) << SHIFT[i++ & 3]; - blocks[i >>> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; - } - } - } - - this.lastByteIndex = i; - this.bytes += i - this.start; - if (i >= 64) { - this.block = blocks[16]; - this.start = i - 64; - this.hash(); - this.hashed = true; - } else { - this.start = i; - } - } - if (this.bytes > 4294967295) { - this.hBytes += this.bytes / 4294967296 << 0; - this.bytes = this.bytes % 4294967296; - } - return this; - }; - - Sha256.prototype.finalize = function () { - if (this.finalized) { - return; - } - this.finalized = true; - var blocks = this.blocks, i = this.lastByteIndex; - blocks[16] = this.block; - blocks[i >>> 2] |= EXTRA[i & 3]; - this.block = blocks[16]; - if (i >= 56) { - if (!this.hashed) { - this.hash(); - } - blocks[0] = this.block; - blocks[16] = blocks[1] = blocks[2] = blocks[3] = - blocks[4] = blocks[5] = blocks[6] = blocks[7] = - blocks[8] = blocks[9] = blocks[10] = blocks[11] = - blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; - } - blocks[14] = this.hBytes << 3 | this.bytes >>> 29; - blocks[15] = this.bytes << 3; - this.hash(); - }; - - Sha256.prototype.hash = function () { - var a = this.h0, b = this.h1, c = this.h2, d = this.h3, e = this.h4, f = this.h5, g = this.h6, - h = this.h7, blocks = this.blocks, j, s0, s1, maj, t1, t2, ch, ab, da, cd, bc; - - for (j = 16; j < 64; ++j) { - // rightrotate - t1 = blocks[j - 15]; - s0 = ((t1 >>> 7) | (t1 << 25)) ^ ((t1 >>> 18) | (t1 << 14)) ^ (t1 >>> 3); - t1 = blocks[j - 2]; - s1 = ((t1 >>> 17) | (t1 << 15)) ^ ((t1 >>> 19) | (t1 << 13)) ^ (t1 >>> 10); - blocks[j] = blocks[j - 16] + s0 + blocks[j - 7] + s1 << 0; - } - - bc = b & c; - for (j = 0; j < 64; j += 4) { - if (this.first) { - if (this.is224) { - ab = 300032; - t1 = blocks[0] - 1413257819; - h = t1 - 150054599 << 0; - d = t1 + 24177077 << 0; - } else { - ab = 704751109; - t1 = blocks[0] - 210244248; - h = t1 - 1521486534 << 0; - d = t1 + 143694565 << 0; - } - this.first = false; - } else { - s0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10)); - s1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7)); - ab = a & b; - maj = ab ^ (a & c) ^ bc; - ch = (e & f) ^ (~e & g); - t1 = h + s1 + ch + K[j] + blocks[j]; - t2 = s0 + maj; - h = d + t1 << 0; - d = t1 + t2 << 0; - } - s0 = ((d >>> 2) | (d << 30)) ^ ((d >>> 13) | (d << 19)) ^ ((d >>> 22) | (d << 10)); - s1 = ((h >>> 6) | (h << 26)) ^ ((h >>> 11) | (h << 21)) ^ ((h >>> 25) | (h << 7)); - da = d & a; - maj = da ^ (d & b) ^ ab; - ch = (h & e) ^ (~h & f); - t1 = g + s1 + ch + K[j + 1] + blocks[j + 1]; - t2 = s0 + maj; - g = c + t1 << 0; - c = t1 + t2 << 0; - s0 = ((c >>> 2) | (c << 30)) ^ ((c >>> 13) | (c << 19)) ^ ((c >>> 22) | (c << 10)); - s1 = ((g >>> 6) | (g << 26)) ^ ((g >>> 11) | (g << 21)) ^ ((g >>> 25) | (g << 7)); - cd = c & d; - maj = cd ^ (c & a) ^ da; - ch = (g & h) ^ (~g & e); - t1 = f + s1 + ch + K[j + 2] + blocks[j + 2]; - t2 = s0 + maj; - f = b + t1 << 0; - b = t1 + t2 << 0; - s0 = ((b >>> 2) | (b << 30)) ^ ((b >>> 13) | (b << 19)) ^ ((b >>> 22) | (b << 10)); - s1 = ((f >>> 6) | (f << 26)) ^ ((f >>> 11) | (f << 21)) ^ ((f >>> 25) | (f << 7)); - bc = b & c; - maj = bc ^ (b & d) ^ cd; - ch = (f & g) ^ (~f & h); - t1 = e + s1 + ch + K[j + 3] + blocks[j + 3]; - t2 = s0 + maj; - e = a + t1 << 0; - a = t1 + t2 << 0; - this.chromeBugWorkAround = true; - } - - this.h0 = this.h0 + a << 0; - this.h1 = this.h1 + b << 0; - this.h2 = this.h2 + c << 0; - this.h3 = this.h3 + d << 0; - this.h4 = this.h4 + e << 0; - this.h5 = this.h5 + f << 0; - this.h6 = this.h6 + g << 0; - this.h7 = this.h7 + h << 0; - }; - - Sha256.prototype.hex = function () { - this.finalize(); - - var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3, h4 = this.h4, h5 = this.h5, - h6 = this.h6, h7 = this.h7; - - var hex = HEX_CHARS[(h0 >>> 28) & 0x0F] + HEX_CHARS[(h0 >>> 24) & 0x0F] + - HEX_CHARS[(h0 >>> 20) & 0x0F] + HEX_CHARS[(h0 >>> 16) & 0x0F] + - HEX_CHARS[(h0 >>> 12) & 0x0F] + HEX_CHARS[(h0 >>> 8) & 0x0F] + - HEX_CHARS[(h0 >>> 4) & 0x0F] + HEX_CHARS[h0 & 0x0F] + - HEX_CHARS[(h1 >>> 28) & 0x0F] + HEX_CHARS[(h1 >>> 24) & 0x0F] + - HEX_CHARS[(h1 >>> 20) & 0x0F] + HEX_CHARS[(h1 >>> 16) & 0x0F] + - HEX_CHARS[(h1 >>> 12) & 0x0F] + HEX_CHARS[(h1 >>> 8) & 0x0F] + - HEX_CHARS[(h1 >>> 4) & 0x0F] + HEX_CHARS[h1 & 0x0F] + - HEX_CHARS[(h2 >>> 28) & 0x0F] + HEX_CHARS[(h2 >>> 24) & 0x0F] + - HEX_CHARS[(h2 >>> 20) & 0x0F] + HEX_CHARS[(h2 >>> 16) & 0x0F] + - HEX_CHARS[(h2 >>> 12) & 0x0F] + HEX_CHARS[(h2 >>> 8) & 0x0F] + - HEX_CHARS[(h2 >>> 4) & 0x0F] + HEX_CHARS[h2 & 0x0F] + - HEX_CHARS[(h3 >>> 28) & 0x0F] + HEX_CHARS[(h3 >>> 24) & 0x0F] + - HEX_CHARS[(h3 >>> 20) & 0x0F] + HEX_CHARS[(h3 >>> 16) & 0x0F] + - HEX_CHARS[(h3 >>> 12) & 0x0F] + HEX_CHARS[(h3 >>> 8) & 0x0F] + - HEX_CHARS[(h3 >>> 4) & 0x0F] + HEX_CHARS[h3 & 0x0F] + - HEX_CHARS[(h4 >>> 28) & 0x0F] + HEX_CHARS[(h4 >>> 24) & 0x0F] + - HEX_CHARS[(h4 >>> 20) & 0x0F] + HEX_CHARS[(h4 >>> 16) & 0x0F] + - HEX_CHARS[(h4 >>> 12) & 0x0F] + HEX_CHARS[(h4 >>> 8) & 0x0F] + - HEX_CHARS[(h4 >>> 4) & 0x0F] + HEX_CHARS[h4 & 0x0F] + - HEX_CHARS[(h5 >>> 28) & 0x0F] + HEX_CHARS[(h5 >>> 24) & 0x0F] + - HEX_CHARS[(h5 >>> 20) & 0x0F] + HEX_CHARS[(h5 >>> 16) & 0x0F] + - HEX_CHARS[(h5 >>> 12) & 0x0F] + HEX_CHARS[(h5 >>> 8) & 0x0F] + - HEX_CHARS[(h5 >>> 4) & 0x0F] + HEX_CHARS[h5 & 0x0F] + - HEX_CHARS[(h6 >>> 28) & 0x0F] + HEX_CHARS[(h6 >>> 24) & 0x0F] + - HEX_CHARS[(h6 >>> 20) & 0x0F] + HEX_CHARS[(h6 >>> 16) & 0x0F] + - HEX_CHARS[(h6 >>> 12) & 0x0F] + HEX_CHARS[(h6 >>> 8) & 0x0F] + - HEX_CHARS[(h6 >>> 4) & 0x0F] + HEX_CHARS[h6 & 0x0F]; - if (!this.is224) { - hex += HEX_CHARS[(h7 >>> 28) & 0x0F] + HEX_CHARS[(h7 >>> 24) & 0x0F] + - HEX_CHARS[(h7 >>> 20) & 0x0F] + HEX_CHARS[(h7 >>> 16) & 0x0F] + - HEX_CHARS[(h7 >>> 12) & 0x0F] + HEX_CHARS[(h7 >>> 8) & 0x0F] + - HEX_CHARS[(h7 >>> 4) & 0x0F] + HEX_CHARS[h7 & 0x0F]; - } - return hex; - }; - - Sha256.prototype.toString = Sha256.prototype.hex; - - Sha256.prototype.digest = function () { - this.finalize(); - - var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3, h4 = this.h4, h5 = this.h5, - h6 = this.h6, h7 = this.h7; - - var arr = [ - (h0 >>> 24) & 0xFF, (h0 >>> 16) & 0xFF, (h0 >>> 8) & 0xFF, h0 & 0xFF, - (h1 >>> 24) & 0xFF, (h1 >>> 16) & 0xFF, (h1 >>> 8) & 0xFF, h1 & 0xFF, - (h2 >>> 24) & 0xFF, (h2 >>> 16) & 0xFF, (h2 >>> 8) & 0xFF, h2 & 0xFF, - (h3 >>> 24) & 0xFF, (h3 >>> 16) & 0xFF, (h3 >>> 8) & 0xFF, h3 & 0xFF, - (h4 >>> 24) & 0xFF, (h4 >>> 16) & 0xFF, (h4 >>> 8) & 0xFF, h4 & 0xFF, - (h5 >>> 24) & 0xFF, (h5 >>> 16) & 0xFF, (h5 >>> 8) & 0xFF, h5 & 0xFF, - (h6 >>> 24) & 0xFF, (h6 >>> 16) & 0xFF, (h6 >>> 8) & 0xFF, h6 & 0xFF - ]; - if (!this.is224) { - arr.push((h7 >>> 24) & 0xFF, (h7 >>> 16) & 0xFF, (h7 >>> 8) & 0xFF, h7 & 0xFF); - } - return arr; - }; - - Sha256.prototype.array = Sha256.prototype.digest; - - Sha256.prototype.arrayBuffer = function () { - this.finalize(); - - var buffer = new ArrayBuffer(this.is224 ? 28 : 32); - var dataView = new DataView(buffer); - dataView.setUint32(0, this.h0); - dataView.setUint32(4, this.h1); - dataView.setUint32(8, this.h2); - dataView.setUint32(12, this.h3); - dataView.setUint32(16, this.h4); - dataView.setUint32(20, this.h5); - dataView.setUint32(24, this.h6); - if (!this.is224) { - dataView.setUint32(28, this.h7); - } - return buffer; - }; - - function HmacSha256(key, is224, sharedMemory) { - var i, type = typeof key; - if (type === 'string') { - var bytes = [], length = key.length, index = 0, code; - for (i = 0; i < length; ++i) { - code = key.charCodeAt(i); - if (code < 0x80) { - bytes[index++] = code; - } else if (code < 0x800) { - bytes[index++] = (0xc0 | (code >>> 6)); - bytes[index++] = (0x80 | (code & 0x3f)); - } else if (code < 0xd800 || code >= 0xe000) { - bytes[index++] = (0xe0 | (code >>> 12)); - bytes[index++] = (0x80 | ((code >>> 6) & 0x3f)); - bytes[index++] = (0x80 | (code & 0x3f)); - } else { - code = 0x10000 + (((code & 0x3ff) << 10) | (key.charCodeAt(++i) & 0x3ff)); - bytes[index++] = (0xf0 | (code >>> 18)); - bytes[index++] = (0x80 | ((code >>> 12) & 0x3f)); - bytes[index++] = (0x80 | ((code >>> 6) & 0x3f)); - bytes[index++] = (0x80 | (code & 0x3f)); - } - } - key = bytes; - } else { - if (type === 'object') { - if (key === null) { - throw new Error(ERROR); - } else if (ARRAY_BUFFER && key.constructor === ArrayBuffer) { - key = new Uint8Array(key); - } else if (!Array.isArray(key)) { - if (!ARRAY_BUFFER || !ArrayBuffer.isView(key)) { - throw new Error(ERROR); - } - } - } else { - throw new Error(ERROR); - } - } - - if (key.length > 64) { - key = (new Sha256(is224, true)).update(key).array(); - } - - var oKeyPad = [], iKeyPad = []; - for (i = 0; i < 64; ++i) { - var b = key[i] || 0; - oKeyPad[i] = 0x5c ^ b; - iKeyPad[i] = 0x36 ^ b; - } - - Sha256.call(this, is224, sharedMemory); - - this.update(iKeyPad); - this.oKeyPad = oKeyPad; - this.inner = true; - this.sharedMemory = sharedMemory; - } - HmacSha256.prototype = new Sha256(); - - HmacSha256.prototype.finalize = function () { - Sha256.prototype.finalize.call(this); - if (this.inner) { - this.inner = false; - var innerHash = this.array(); - Sha256.call(this, this.is224, this.sharedMemory); - this.update(this.oKeyPad); - this.update(innerHash); - Sha256.prototype.finalize.call(this); - } - }; - - var exports = createMethod(); - exports.sha256 = exports; - exports.sha224 = createMethod(true); - exports.sha256.hmac = createHmacMethod(); - exports.sha224.hmac = createHmacMethod(true); - - if (COMMON_JS) { - module.exports = exports; - } else { - root.sha256 = exports.sha256; - root.sha224 = exports.sha224; - if (AMD) { - define(function () { - return exports; - }); - } - } -})(); \ No newline at end of file +// (function () { +// 'use strict'; +// +// var ERROR = 'input is invalid type'; +// var WINDOW = typeof window === 'object'; +// var root = WINDOW ? window : {}; +// if (root.JS_SHA256_NO_WINDOW) { +// WINDOW = false; +// } +// var WEB_WORKER = !WINDOW && typeof self === 'object'; +// var NODE_JS = !root.JS_SHA256_NO_NODE_JS && typeof process === 'object' && process.versions && process.versions.node; +// if (NODE_JS) { +// root = global; +// } else if (WEB_WORKER) { +// root = self; +// } +// var COMMON_JS = !root.JS_SHA256_NO_COMMON_JS && typeof module === 'object' && module.exports; +// var AMD = typeof define === 'function' && define.amd; +// var ARRAY_BUFFER = !root.JS_SHA256_NO_ARRAY_BUFFER && typeof ArrayBuffer !== 'undefined'; +// var HEX_CHARS = '0123456789abcdef'.split(''); +// var EXTRA = [-2147483648, 8388608, 32768, 128]; +// var SHIFT = [24, 16, 8, 0]; +// var K = [ +// 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, +// 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, +// 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, +// 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, +// 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, +// 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, +// 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, +// 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 +// ]; +// var OUTPUT_TYPES = ['hex', 'array', 'digest', 'arrayBuffer']; +// +// var blocks = []; +// +// if (root.JS_SHA256_NO_NODE_JS || !Array.isArray) { +// Array.isArray = function (obj) { +// return Object.prototype.toString.call(obj) === '[object Array]'; +// }; +// } +// +// if (ARRAY_BUFFER && (root.JS_SHA256_NO_ARRAY_BUFFER_IS_VIEW || !ArrayBuffer.isView)) { +// ArrayBuffer.isView = function (obj) { +// return typeof obj === 'object' && obj.buffer && obj.buffer.constructor === ArrayBuffer; +// }; +// } +// +// var createOutputMethod = function (outputType, is224) { +// return function (message) { +// return new Sha256(is224, true).update(message)[outputType](); +// }; +// }; +// +// var createMethod = function (is224) { +// var method = createOutputMethod('hex', is224); +// if (NODE_JS) { +// method = nodeWrap(method, is224); +// } +// method.create = function () { +// return new Sha256(is224); +// }; +// method.update = function (message) { +// return method.create().update(message); +// }; +// for (var i = 0; i < OUTPUT_TYPES.length; ++i) { +// var type = OUTPUT_TYPES[i]; +// method[type] = createOutputMethod(type, is224); +// } +// return method; +// }; +// +// var nodeWrap = function (method, is224) { +// var crypto = require('crypto') +// var Buffer = require('buffer').Buffer; +// var algorithm = is224 ? 'sha224' : 'sha256'; +// var bufferFrom; +// if (Buffer.from && !root.JS_SHA256_NO_BUFFER_FROM) { +// bufferFrom = Buffer.from; +// } else { +// bufferFrom = function (message) { +// return new Buffer(message); +// }; +// } +// var nodeMethod = function (message) { +// if (typeof message === 'string') { +// return crypto.createHash(algorithm).update(message, 'utf8').digest('hex'); +// } else { +// if (message === null || message === undefined) { +// throw new Error(ERROR); +// } else if (message.constructor === ArrayBuffer) { +// message = new Uint8Array(message); +// } +// } +// if (Array.isArray(message) || ArrayBuffer.isView(message) || +// message.constructor === Buffer) { +// return crypto.createHash(algorithm).update(bufferFrom(message)).digest('hex'); +// } else { +// return method(message); +// } +// }; +// return nodeMethod; +// }; +// +// var createHmacOutputMethod = function (outputType, is224) { +// return function (key, message) { +// return new HmacSha256(key, is224, true).update(message)[outputType](); +// }; +// }; +// +// var createHmacMethod = function (is224) { +// var method = createHmacOutputMethod('hex', is224); +// method.create = function (key) { +// return new HmacSha256(key, is224); +// }; +// method.update = function (key, message) { +// return method.create(key).update(message); +// }; +// for (var i = 0; i < OUTPUT_TYPES.length; ++i) { +// var type = OUTPUT_TYPES[i]; +// method[type] = createHmacOutputMethod(type, is224); +// } +// return method; +// }; +// +// function Sha256(is224, sharedMemory) { +// if (sharedMemory) { +// blocks[0] = blocks[16] = blocks[1] = blocks[2] = blocks[3] = +// blocks[4] = blocks[5] = blocks[6] = blocks[7] = +// blocks[8] = blocks[9] = blocks[10] = blocks[11] = +// blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; +// this.blocks = blocks; +// } else { +// this.blocks = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; +// } +// +// if (is224) { +// this.h0 = 0xc1059ed8; +// this.h1 = 0x367cd507; +// this.h2 = 0x3070dd17; +// this.h3 = 0xf70e5939; +// this.h4 = 0xffc00b31; +// this.h5 = 0x68581511; +// this.h6 = 0x64f98fa7; +// this.h7 = 0xbefa4fa4; +// } else { // 256 +// this.h0 = 0x6a09e667; +// this.h1 = 0xbb67ae85; +// this.h2 = 0x3c6ef372; +// this.h3 = 0xa54ff53a; +// this.h4 = 0x510e527f; +// this.h5 = 0x9b05688c; +// this.h6 = 0x1f83d9ab; +// this.h7 = 0x5be0cd19; +// } +// +// this.block = this.start = this.bytes = this.hBytes = 0; +// this.finalized = this.hashed = false; +// this.first = true; +// this.is224 = is224; +// } +// +// Sha256.prototype.update = function (message) { +// if (this.finalized) { +// return; +// } +// var notString, type = typeof message; +// if (type !== 'string') { +// if (type === 'object') { +// if (message === null) { +// throw new Error(ERROR); +// } else if (ARRAY_BUFFER && message.constructor === ArrayBuffer) { +// message = new Uint8Array(message); +// } else if (!Array.isArray(message)) { +// if (!ARRAY_BUFFER || !ArrayBuffer.isView(message)) { +// throw new Error(ERROR); +// } +// } +// } else { +// throw new Error(ERROR); +// } +// notString = true; +// } +// var code, index = 0, i, length = message.length, blocks = this.blocks; +// while (index < length) { +// if (this.hashed) { +// this.hashed = false; +// blocks[0] = this.block; +// this.block = blocks[16] = blocks[1] = blocks[2] = blocks[3] = +// blocks[4] = blocks[5] = blocks[6] = blocks[7] = +// blocks[8] = blocks[9] = blocks[10] = blocks[11] = +// blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; +// } +// +// if (notString) { +// for (i = this.start; index < length && i < 64; ++index) { +// blocks[i >>> 2] |= message[index] << SHIFT[i++ & 3]; +// } +// } else { +// for (i = this.start; index < length && i < 64; ++index) { +// code = message.charCodeAt(index); +// if (code < 0x80) { +// blocks[i >>> 2] |= code << SHIFT[i++ & 3]; +// } else if (code < 0x800) { +// blocks[i >>> 2] |= (0xc0 | (code >>> 6)) << SHIFT[i++ & 3]; +// blocks[i >>> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; +// } else if (code < 0xd800 || code >= 0xe000) { +// blocks[i >>> 2] |= (0xe0 | (code >>> 12)) << SHIFT[i++ & 3]; +// blocks[i >>> 2] |= (0x80 | ((code >>> 6) & 0x3f)) << SHIFT[i++ & 3]; +// blocks[i >>> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; +// } else { +// code = 0x10000 + (((code & 0x3ff) << 10) | (message.charCodeAt(++index) & 0x3ff)); +// blocks[i >>> 2] |= (0xf0 | (code >>> 18)) << SHIFT[i++ & 3]; +// blocks[i >>> 2] |= (0x80 | ((code >>> 12) & 0x3f)) << SHIFT[i++ & 3]; +// blocks[i >>> 2] |= (0x80 | ((code >>> 6) & 0x3f)) << SHIFT[i++ & 3]; +// blocks[i >>> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; +// } +// } +// } +// +// this.lastByteIndex = i; +// this.bytes += i - this.start; +// if (i >= 64) { +// this.block = blocks[16]; +// this.start = i - 64; +// this.hash(); +// this.hashed = true; +// } else { +// this.start = i; +// } +// } +// if (this.bytes > 4294967295) { +// this.hBytes += this.bytes / 4294967296 << 0; +// this.bytes = this.bytes % 4294967296; +// } +// return this; +// }; +// +// Sha256.prototype.finalize = function () { +// if (this.finalized) { +// return; +// } +// this.finalized = true; +// var blocks = this.blocks, i = this.lastByteIndex; +// blocks[16] = this.block; +// blocks[i >>> 2] |= EXTRA[i & 3]; +// this.block = blocks[16]; +// if (i >= 56) { +// if (!this.hashed) { +// this.hash(); +// } +// blocks[0] = this.block; +// blocks[16] = blocks[1] = blocks[2] = blocks[3] = +// blocks[4] = blocks[5] = blocks[6] = blocks[7] = +// blocks[8] = blocks[9] = blocks[10] = blocks[11] = +// blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; +// } +// blocks[14] = this.hBytes << 3 | this.bytes >>> 29; +// blocks[15] = this.bytes << 3; +// this.hash(); +// }; +// +// Sha256.prototype.hash = function () { +// var a = this.h0, b = this.h1, c = this.h2, d = this.h3, e = this.h4, f = this.h5, g = this.h6, +// h = this.h7, blocks = this.blocks, j, s0, s1, maj, t1, t2, ch, ab, da, cd, bc; +// +// for (j = 16; j < 64; ++j) { +// // rightrotate +// t1 = blocks[j - 15]; +// s0 = ((t1 >>> 7) | (t1 << 25)) ^ ((t1 >>> 18) | (t1 << 14)) ^ (t1 >>> 3); +// t1 = blocks[j - 2]; +// s1 = ((t1 >>> 17) | (t1 << 15)) ^ ((t1 >>> 19) | (t1 << 13)) ^ (t1 >>> 10); +// blocks[j] = blocks[j - 16] + s0 + blocks[j - 7] + s1 << 0; +// } +// +// bc = b & c; +// for (j = 0; j < 64; j += 4) { +// if (this.first) { +// if (this.is224) { +// ab = 300032; +// t1 = blocks[0] - 1413257819; +// h = t1 - 150054599 << 0; +// d = t1 + 24177077 << 0; +// } else { +// ab = 704751109; +// t1 = blocks[0] - 210244248; +// h = t1 - 1521486534 << 0; +// d = t1 + 143694565 << 0; +// } +// this.first = false; +// } else { +// s0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10)); +// s1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7)); +// ab = a & b; +// maj = ab ^ (a & c) ^ bc; +// ch = (e & f) ^ (~e & g); +// t1 = h + s1 + ch + K[j] + blocks[j]; +// t2 = s0 + maj; +// h = d + t1 << 0; +// d = t1 + t2 << 0; +// } +// s0 = ((d >>> 2) | (d << 30)) ^ ((d >>> 13) | (d << 19)) ^ ((d >>> 22) | (d << 10)); +// s1 = ((h >>> 6) | (h << 26)) ^ ((h >>> 11) | (h << 21)) ^ ((h >>> 25) | (h << 7)); +// da = d & a; +// maj = da ^ (d & b) ^ ab; +// ch = (h & e) ^ (~h & f); +// t1 = g + s1 + ch + K[j + 1] + blocks[j + 1]; +// t2 = s0 + maj; +// g = c + t1 << 0; +// c = t1 + t2 << 0; +// s0 = ((c >>> 2) | (c << 30)) ^ ((c >>> 13) | (c << 19)) ^ ((c >>> 22) | (c << 10)); +// s1 = ((g >>> 6) | (g << 26)) ^ ((g >>> 11) | (g << 21)) ^ ((g >>> 25) | (g << 7)); +// cd = c & d; +// maj = cd ^ (c & a) ^ da; +// ch = (g & h) ^ (~g & e); +// t1 = f + s1 + ch + K[j + 2] + blocks[j + 2]; +// t2 = s0 + maj; +// f = b + t1 << 0; +// b = t1 + t2 << 0; +// s0 = ((b >>> 2) | (b << 30)) ^ ((b >>> 13) | (b << 19)) ^ ((b >>> 22) | (b << 10)); +// s1 = ((f >>> 6) | (f << 26)) ^ ((f >>> 11) | (f << 21)) ^ ((f >>> 25) | (f << 7)); +// bc = b & c; +// maj = bc ^ (b & d) ^ cd; +// ch = (f & g) ^ (~f & h); +// t1 = e + s1 + ch + K[j + 3] + blocks[j + 3]; +// t2 = s0 + maj; +// e = a + t1 << 0; +// a = t1 + t2 << 0; +// this.chromeBugWorkAround = true; +// } +// +// this.h0 = this.h0 + a << 0; +// this.h1 = this.h1 + b << 0; +// this.h2 = this.h2 + c << 0; +// this.h3 = this.h3 + d << 0; +// this.h4 = this.h4 + e << 0; +// this.h5 = this.h5 + f << 0; +// this.h6 = this.h6 + g << 0; +// this.h7 = this.h7 + h << 0; +// }; +// +// Sha256.prototype.hex = function () { +// this.finalize(); +// +// var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3, h4 = this.h4, h5 = this.h5, +// h6 = this.h6, h7 = this.h7; +// +// var hex = HEX_CHARS[(h0 >>> 28) & 0x0F] + HEX_CHARS[(h0 >>> 24) & 0x0F] + +// HEX_CHARS[(h0 >>> 20) & 0x0F] + HEX_CHARS[(h0 >>> 16) & 0x0F] + +// HEX_CHARS[(h0 >>> 12) & 0x0F] + HEX_CHARS[(h0 >>> 8) & 0x0F] + +// HEX_CHARS[(h0 >>> 4) & 0x0F] + HEX_CHARS[h0 & 0x0F] + +// HEX_CHARS[(h1 >>> 28) & 0x0F] + HEX_CHARS[(h1 >>> 24) & 0x0F] + +// HEX_CHARS[(h1 >>> 20) & 0x0F] + HEX_CHARS[(h1 >>> 16) & 0x0F] + +// HEX_CHARS[(h1 >>> 12) & 0x0F] + HEX_CHARS[(h1 >>> 8) & 0x0F] + +// HEX_CHARS[(h1 >>> 4) & 0x0F] + HEX_CHARS[h1 & 0x0F] + +// HEX_CHARS[(h2 >>> 28) & 0x0F] + HEX_CHARS[(h2 >>> 24) & 0x0F] + +// HEX_CHARS[(h2 >>> 20) & 0x0F] + HEX_CHARS[(h2 >>> 16) & 0x0F] + +// HEX_CHARS[(h2 >>> 12) & 0x0F] + HEX_CHARS[(h2 >>> 8) & 0x0F] + +// HEX_CHARS[(h2 >>> 4) & 0x0F] + HEX_CHARS[h2 & 0x0F] + +// HEX_CHARS[(h3 >>> 28) & 0x0F] + HEX_CHARS[(h3 >>> 24) & 0x0F] + +// HEX_CHARS[(h3 >>> 20) & 0x0F] + HEX_CHARS[(h3 >>> 16) & 0x0F] + +// HEX_CHARS[(h3 >>> 12) & 0x0F] + HEX_CHARS[(h3 >>> 8) & 0x0F] + +// HEX_CHARS[(h3 >>> 4) & 0x0F] + HEX_CHARS[h3 & 0x0F] + +// HEX_CHARS[(h4 >>> 28) & 0x0F] + HEX_CHARS[(h4 >>> 24) & 0x0F] + +// HEX_CHARS[(h4 >>> 20) & 0x0F] + HEX_CHARS[(h4 >>> 16) & 0x0F] + +// HEX_CHARS[(h4 >>> 12) & 0x0F] + HEX_CHARS[(h4 >>> 8) & 0x0F] + +// HEX_CHARS[(h4 >>> 4) & 0x0F] + HEX_CHARS[h4 & 0x0F] + +// HEX_CHARS[(h5 >>> 28) & 0x0F] + HEX_CHARS[(h5 >>> 24) & 0x0F] + +// HEX_CHARS[(h5 >>> 20) & 0x0F] + HEX_CHARS[(h5 >>> 16) & 0x0F] + +// HEX_CHARS[(h5 >>> 12) & 0x0F] + HEX_CHARS[(h5 >>> 8) & 0x0F] + +// HEX_CHARS[(h5 >>> 4) & 0x0F] + HEX_CHARS[h5 & 0x0F] + +// HEX_CHARS[(h6 >>> 28) & 0x0F] + HEX_CHARS[(h6 >>> 24) & 0x0F] + +// HEX_CHARS[(h6 >>> 20) & 0x0F] + HEX_CHARS[(h6 >>> 16) & 0x0F] + +// HEX_CHARS[(h6 >>> 12) & 0x0F] + HEX_CHARS[(h6 >>> 8) & 0x0F] + +// HEX_CHARS[(h6 >>> 4) & 0x0F] + HEX_CHARS[h6 & 0x0F]; +// if (!this.is224) { +// hex += HEX_CHARS[(h7 >>> 28) & 0x0F] + HEX_CHARS[(h7 >>> 24) & 0x0F] + +// HEX_CHARS[(h7 >>> 20) & 0x0F] + HEX_CHARS[(h7 >>> 16) & 0x0F] + +// HEX_CHARS[(h7 >>> 12) & 0x0F] + HEX_CHARS[(h7 >>> 8) & 0x0F] + +// HEX_CHARS[(h7 >>> 4) & 0x0F] + HEX_CHARS[h7 & 0x0F]; +// } +// return hex; +// }; +// +// Sha256.prototype.toString = Sha256.prototype.hex; +// +// Sha256.prototype.digest = function () { +// this.finalize(); +// +// var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3, h4 = this.h4, h5 = this.h5, +// h6 = this.h6, h7 = this.h7; +// +// var arr = [ +// (h0 >>> 24) & 0xFF, (h0 >>> 16) & 0xFF, (h0 >>> 8) & 0xFF, h0 & 0xFF, +// (h1 >>> 24) & 0xFF, (h1 >>> 16) & 0xFF, (h1 >>> 8) & 0xFF, h1 & 0xFF, +// (h2 >>> 24) & 0xFF, (h2 >>> 16) & 0xFF, (h2 >>> 8) & 0xFF, h2 & 0xFF, +// (h3 >>> 24) & 0xFF, (h3 >>> 16) & 0xFF, (h3 >>> 8) & 0xFF, h3 & 0xFF, +// (h4 >>> 24) & 0xFF, (h4 >>> 16) & 0xFF, (h4 >>> 8) & 0xFF, h4 & 0xFF, +// (h5 >>> 24) & 0xFF, (h5 >>> 16) & 0xFF, (h5 >>> 8) & 0xFF, h5 & 0xFF, +// (h6 >>> 24) & 0xFF, (h6 >>> 16) & 0xFF, (h6 >>> 8) & 0xFF, h6 & 0xFF +// ]; +// if (!this.is224) { +// arr.push((h7 >>> 24) & 0xFF, (h7 >>> 16) & 0xFF, (h7 >>> 8) & 0xFF, h7 & 0xFF); +// } +// return arr; +// }; +// +// Sha256.prototype.array = Sha256.prototype.digest; +// +// Sha256.prototype.arrayBuffer = function () { +// this.finalize(); +// +// var buffer = new ArrayBuffer(this.is224 ? 28 : 32); +// var dataView = new DataView(buffer); +// dataView.setUint32(0, this.h0); +// dataView.setUint32(4, this.h1); +// dataView.setUint32(8, this.h2); +// dataView.setUint32(12, this.h3); +// dataView.setUint32(16, this.h4); +// dataView.setUint32(20, this.h5); +// dataView.setUint32(24, this.h6); +// if (!this.is224) { +// dataView.setUint32(28, this.h7); +// } +// return buffer; +// }; +// +// function HmacSha256(key, is224, sharedMemory) { +// var i, type = typeof key; +// if (type === 'string') { +// var bytes = [], length = key.length, index = 0, code; +// for (i = 0; i < length; ++i) { +// code = key.charCodeAt(i); +// if (code < 0x80) { +// bytes[index++] = code; +// } else if (code < 0x800) { +// bytes[index++] = (0xc0 | (code >>> 6)); +// bytes[index++] = (0x80 | (code & 0x3f)); +// } else if (code < 0xd800 || code >= 0xe000) { +// bytes[index++] = (0xe0 | (code >>> 12)); +// bytes[index++] = (0x80 | ((code >>> 6) & 0x3f)); +// bytes[index++] = (0x80 | (code & 0x3f)); +// } else { +// code = 0x10000 + (((code & 0x3ff) << 10) | (key.charCodeAt(++i) & 0x3ff)); +// bytes[index++] = (0xf0 | (code >>> 18)); +// bytes[index++] = (0x80 | ((code >>> 12) & 0x3f)); +// bytes[index++] = (0x80 | ((code >>> 6) & 0x3f)); +// bytes[index++] = (0x80 | (code & 0x3f)); +// } +// } +// key = bytes; +// } else { +// if (type === 'object') { +// if (key === null) { +// throw new Error(ERROR); +// } else if (ARRAY_BUFFER && key.constructor === ArrayBuffer) { +// key = new Uint8Array(key); +// } else if (!Array.isArray(key)) { +// if (!ARRAY_BUFFER || !ArrayBuffer.isView(key)) { +// throw new Error(ERROR); +// } +// } +// } else { +// throw new Error(ERROR); +// } +// } +// +// if (key.length > 64) { +// key = (new Sha256(is224, true)).update(key).array(); +// } +// +// var oKeyPad = [], iKeyPad = []; +// for (i = 0; i < 64; ++i) { +// var b = key[i] || 0; +// oKeyPad[i] = 0x5c ^ b; +// iKeyPad[i] = 0x36 ^ b; +// } +// +// Sha256.call(this, is224, sharedMemory); +// +// this.update(iKeyPad); +// this.oKeyPad = oKeyPad; +// this.inner = true; +// this.sharedMemory = sharedMemory; +// } +// HmacSha256.prototype = new Sha256(); +// +// HmacSha256.prototype.finalize = function () { +// Sha256.prototype.finalize.call(this); +// if (this.inner) { +// this.inner = false; +// var innerHash = this.array(); +// Sha256.call(this, this.is224, this.sharedMemory); +// this.update(this.oKeyPad); +// this.update(innerHash); +// Sha256.prototype.finalize.call(this); +// } +// }; +// +// var exports = createMethod(); +// exports.sha256 = exports; +// exports.sha224 = createMethod(true); +// exports.sha256.hmac = createHmacMethod(); +// exports.sha224.hmac = createHmacMethod(true); +// +// if (COMMON_JS) { +// module.exports = exports; +// } else { +// root.sha256 = exports.sha256; +// root.sha224 = exports.sha224; +// if (AMD) { +// define(function () { +// return exports; +// }); +// } +// } +// })(); \ No newline at end of file diff --git a/src/main/resources/static/js/template.js b/src/main/resources/static/js/template.js new file mode 100644 index 0000000..b69f3bc --- /dev/null +++ b/src/main/resources/static/js/template.js @@ -0,0 +1,411 @@ +/* + * Arcana by HTML5 UP (html5up.net | @ajlkn) + * Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) + * + * This file contains the theme's utility scripts (panel, polyfills, etc.) + */ + +(function($) { + + /** + * Converts a navigation menu (ul) into a flat list of links for use in a mobile panel. + * @return {jQuery} jQuery object + */ + $.fn.navList = function() { + + var $this = $(this), + $a = $this.find('a'), + b = []; + + $a.each(function() { + var $this = $(this), + indent = Math.max(0, $this.parents('li').length - 1), + href = $this.attr('href'), + target = $this.attr('target'), + // [Modified] Get the class and 'to' attribute of the original link to open the popup. + originalClass = $this.attr('class'), + toAttr = $this.attr('to'); + + // [Modified] Include both the default class and the original class for the mobile panel link. + var classes = 'link depth-' + indent; + if (typeof originalClass !== 'undefined' && originalClass != '') { + classes += ' ' + originalClass; + } + + b.push( + '' + + '' + + $this.text() + + '' + ); + }); + + return b.join(''); + + }; + + /** + * Transforms a specific element into a slide-out panel. + * @param {object} userConfig User settings object + * @return {jQuery} jQuery object + */ + $.fn.panel = function(userConfig) { + + // Return if no element + if (this.length == 0) + return $this; + + // If multiple elements, recursively call for each + if (this.length > 1) { + for (var i=0; i < this.length; i++) + $(this[i]).panel(userConfig); + return $this; + } + + // Set variables + var $this = $(this), + $body = $('body'), + $window = $(window), + id = $this.attr('id'), + config; + + // Merge default and user settings + config = $.extend({ + delay: 0, // Delay time + hideOnClick: false, // Whether to hide the panel on link click + hideOnEscape: false, // Whether to hide the panel on ESC key press + hideOnSwipe: false, // Whether to hide the panel on swipe + resetScroll: false, // Whether to reset scroll on hide + resetForms: false, // Whether to reset forms on hide + side: null, // Which side the panel appears on (top, bottom, left, right) + target: $this, // The target to which the class is applied when the panel is visible + visibleClass: 'visible' // The class name to apply when the panel is visible + }, userConfig); + + // Internal function to hide the panel + $this._hide = function(event) { + if (!config.target.hasClass(config.visibleClass)) + return; + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + config.target.removeClass(config.visibleClass); + window.setTimeout(function() { + if (config.resetScroll) + $this.scrollTop(0); + if (config.resetForms) + $this.find('form').each(function() { + this.reset(); + }); + }, config.delay); + }; + + // CSS settings for browser compatibility + $this + .css('-ms-overflow-style', '-ms-autohiding-scrollbar') + .css('-webkit-overflow-scrolling', 'touch'); + + // Event handler to hide the panel on link click + if (config.hideOnClick) { + + $this.find('a') + .css('-webkit-tap-highlight-color', 'rgba(0,0,0,0)'); + + $this + .on('click', 'a', function(event) { + + var $a = $(this), + href = $a.attr('href'), + target = $a.attr('target'); + + // --- ⬇️ Modified Logic Start ⬇️ --- + + // 1. First check if it is a popup link. + // If the link has the 'open-login-popup' class, close the menu, and this handler's action ends here. + // (The action to open the popup is handled by another event handler in common.js.) + if ($a.hasClass('open-login-popup')) { + $this._hide(); + return; + } + + // 2. The existing logic for regular links (page navigation) remains unchanged. + if (!href || href == '#' || href == '' || href == '#' + id) + return; + + // --- ⬆️ Modified Logic End ⬆️ --- + + // Cancel original event. + event.preventDefault(); + event.stopPropagation(); + + // Hide panel. + $this._hide(); + + // Redirect to href. + window.setTimeout(function() { + if (target == '_blank') + window.open(href); + else + window.location.href = href; + }, config.delay + 10); + + }); + } + + // Touch and swipe event handlers + $this.on('touchstart', function(event) { + $this.touchPosX = event.originalEvent.touches[0].pageX; + $this.touchPosY = event.originalEvent.touches[0].pageY; + }); + $this.on('touchmove', function(event) { + if ($this.touchPosX === null || $this.touchPosY === null) return; + var diffX = $this.touchPosX - event.originalEvent.touches[0].pageX, + diffY = $this.touchPosY - event.originalEvent.touches[0].pageY, + th = $this.outerHeight(), + ts = ($this.get(0).scrollHeight - $this.scrollTop()); + + if (config.hideOnSwipe) { + var result = false, boundary = 20, delta = 50; + switch (config.side) { + case 'left': result = (diffY < boundary && diffY > (-1 * boundary)) && (diffX > delta); break; + case 'right': result = (diffY < boundary && diffY > (-1 * boundary)) && (diffX < (-1 * delta)); break; + case 'top': result = (diffX < boundary && diffX > (-1 * boundary)) && (diffY > delta); break; + case 'bottom': result = (diffX < boundary && diffX > (-1 * boundary)) && (diffY < (-1 * delta)); break; + default: break; + } + if (result) { + $this.touchPosX = null; + $this.touchPosY = null; + $this._hide(); + return false; + } + } + + if (($this.scrollTop() < 0 && diffY < 0) || (ts > (th - 2) && ts < (th + 2) && diffY > 0)) { + event.preventDefault(); + event.stopPropagation(); + } + }); + + // Prevent events inside the panel from propagating upwards + $this.on('click touchend touchstart touchmove', function(event) { + event.stopPropagation(); + }); + + // Hide panel on click of a link pointing to the panel ID + $this.on('click', 'a[href="#' + id + '"]', function(event) { + event.preventDefault(); + event.stopPropagation(); + config.target.removeClass(config.visibleClass); + }); + + // Hide panel on body click + $body.on('click touchend', function(event) { + $this._hide(event); + }); + + // Event handler for the link (toggle) that opens the panel + $body.on('click', 'a[href="#' + id + '"]', function(event) { + event.preventDefault(); + event.stopPropagation(); + config.target.toggleClass(config.visibleClass); + }); + + // Hide panel on ESC key press + if (config.hideOnEscape) { + $window.on('keydown', function(event) { + if (event.keyCode == 27) $this._hide(event); + }); + } + + return $this; + }; + + /** + * Polyfill for the 'placeholder' attribute of input elements in older browsers. + * @return {jQuery} jQuery object + */ + $.fn.placeholder = function() { + if (typeof (document.createElement('input')).placeholder != 'undefined') + return $(this); + if (this.length == 0) return $this; + if (this.length > 1) { + for (var i=0; i < this.length; i++) $(this[i]).placeholder(); + return $this; + } + var $this = $(this); + $this.find('input[type=text],textarea').each(function() { + var i = $(this); + if (i.val() == '' || i.val() == i.attr('placeholder')) + i.addClass('polyfill-placeholder').val(i.attr('placeholder')); + }).on('blur', function() { + var i = $(this); + if (i.attr('name').match(/-polyfill-field$/)) return; + if (i.val() == '') + i.addClass('polyfill-placeholder').val(i.attr('placeholder')); + }).on('focus', function() { + var i = $(this); + if (i.attr('name').match(/-polyfill-field$/)) return; + if (i.val() == i.attr('placeholder')) + i.removeClass('polyfill-placeholder').val(''); + }); + $this.find('input[type=password]').each(function() { + var i = $(this); + var x = $($('
').append(i.clone()).remove().html().replace(/type="password"/i, 'type="text"').replace(/type=password/i, 'type=text')); + if (i.attr('id') != '') x.attr('id', i.attr('id') + '-polyfill-field'); + if (i.attr('name') != '') x.attr('name', i.attr('name') + '-polyfill-field'); + x.addClass('polyfill-placeholder').val(x.attr('placeholder')).insertAfter(i); + if (i.val() == '') i.hide(); else x.hide(); + i.on('blur', function(event) { + event.preventDefault(); + var x = i.parent().find('input[name=' + i.attr('name') + '-polyfill-field]'); + if (i.val() == '') { i.hide(); x.show(); } + }); + x.on('focus', function(event) { + event.preventDefault(); + var i = x.parent().find('input[name=' + x.attr('name').replace('-polyfill-field', '') + ']'); + x.hide(); + i.show().focus(); + }).on('keypress', function(event) { + event.preventDefault(); + x.val(''); + }); + }); + $this.on('submit', function() { + $this.find('input[type=text],input[type=password],textarea').each(function(event) { + var i = $(this); + if (i.attr('name').match(/-polyfill-field$/)) i.attr('name', ''); + if (i.val() == i.attr('placeholder')) { + i.removeClass('polyfill-placeholder'); + i.val(''); + } + }); + }).on('reset', function(event) { + event.preventDefault(); + $this.find('select').val($('option:first').val()); + $this.find('input,textarea').each(function() { + var i = $(this), x; + i.removeClass('polyfill-placeholder'); + switch (this.type) { + case 'submit': case 'reset': break; + case 'password': + i.val(i.attr('defaultValue')); + x = i.parent().find('input[name=' + i.attr('name') + '-polyfill-field]'); + if (i.val() == '') { i.hide(); x.show(); } else { i.show(); x.hide(); } + break; + case 'checkbox': case 'radio': i.attr('checked', i.attr('defaultValue')); break; + case 'text': case 'textarea': + i.val(i.attr('defaultValue')); + if (i.val() == '') { i.addClass('polyfill-placeholder'); i.val(i.attr('placeholder')); } + break; + default: i.val(i.attr('defaultValue')); break; + } + }); + }); + return $this; + }; + + /** + * Moves elements to the front of their parent or back to their original position based on a condition. + * (Mainly used for changing element positions in responsive layouts.) + * @param {jQuery} $elements Elements to move + * @param {bool} condition If true, move to the front; if false, move to the original position + */ + $.prioritize = function($elements, condition) { + var key = '__prioritize'; + if (typeof $elements != 'jQuery') $elements = $($elements); + $elements.each(function() { + var $e = $(this), $p, $parent = $e.parent(); + if ($parent.length == 0) return; + if (!$e.data(key)) { + if (!condition) return; + $p = $e.prev(); + if ($p.length == 0) return; + $e.prependTo($parent); + $e.data(key, $p); + } else { + if (condition) return; + $p = $e.data(key); + $e.insertAfter($p); + $e.removeData(key); + } + }); + }; + +})(jQuery); + + +/* + Arcana by HTML5 UP + html5up.net | @ajlkn + Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) +*/ + +(function($) { + + var $window = $(window), + $body = $('body'); + + // Breakpoints. + breakpoints({ + wide: [ '1281px', '1680px' ], + normal: [ '981px', '1280px' ], + narrow: [ '841px', '980px' ], + narrower: [ '737px', '840px' ], + mobile: [ '481px', '736px' ], + mobilep: [ null, '480px' ] + }); + + // Play initial animations on page load. + $window.on('load', function() { + window.setTimeout(function() { + $body.removeClass('is-preload'); + }, 100); + }); + + // Dropdowns. + $('#nav > ul').dropotron({ + offsetY: -15, + hoverDelay: 0, + alignment: 'center' + }); + + // Nav. + + // Bar. + $( + '
' + + '' + + '' + $('#logo').html() + '' + + '
' + ) + .appendTo($body); + + // Panel. + $( + '' + ) + .appendTo($body) + .panel({ + delay: 500, + hideOnClick: true, + hideOnSwipe: true, + resetScroll: true, + resetForms: true, + side: 'left', + target: $body, + visibleClass: 'navPanel-visible' + }); + +})(jQuery); \ No newline at end of file diff --git a/src/main/resources/static/js/test.js b/src/main/resources/static/js/test.js deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/static/js/test.js +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/static/js/tj.js b/src/main/resources/static/js/tj.js deleted file mode 100644 index 200b54e..0000000 --- a/src/main/resources/static/js/tj.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! jQuery v3.6.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */ -!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.0",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var _t,zt=[],Ut=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=zt.pop()||S.expando+"_"+wt.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Ut.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Ut.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Ut,"$1"+r):!1!==e.jsonp&&(e.url+=(Tt.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,zt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((_t=E.implementation.createHTMLDocument("").body).innerHTML="
",2===_t.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=Fe(y.pixelPosition,function(e,t){if(t)return t=We(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0' + - '' + - $this.text() + - '' - ); - }); - - return b.join(''); - - }; - - /** - * 특정 요소를 슬라이드 아웃 패널로 변환합니다. - * @param {object} userConfig 사용자 설정 객체 - * @return {jQuery} jQuery 객체 - */ - $.fn.panel = function(userConfig) { - - // 요소가 없으면 반환 - if (this.length == 0) - return $this; - - // 여러 요소에 적용될 경우, 각각에 대해 재귀적으로 호출 - if (this.length > 1) { - for (var i=0; i < this.length; i++) - $(this[i]).panel(userConfig); - return $this; - } - - // 변수 설정 - var $this = $(this), - $body = $('body'), - $window = $(window), - id = $this.attr('id'), - config; - - // 기본 설정과 사용자 설정을 병합 - config = $.extend({ - delay: 0, // 지연 시간 - hideOnClick: false, // 링크 클릭 시 패널 숨김 여부 - hideOnEscape: false, // ESC 키 누를 시 패널 숨김 여부 - hideOnSwipe: false, // 스와이프 시 패널 숨김 여부 - resetScroll: false, // 숨길 때 스크롤 리셋 여부 - resetForms: false, // 숨길 때 폼 리셋 여부 - side: null, // 패널이 나타날 위치 (top, bottom, left, right) - target: $this, // 패널이 보일 때 클래스가 적용될 대상 - visibleClass: 'visible' // 패널이 보일 때 적용될 클래스 이름 - }, userConfig); - - // 패널 숨기기 내부 함수 - $this._hide = function(event) { - if (!config.target.hasClass(config.visibleClass)) - return; - if (event) { - event.preventDefault(); - event.stopPropagation(); - } - config.target.removeClass(config.visibleClass); - window.setTimeout(function() { - if (config.resetScroll) - $this.scrollTop(0); - if (config.resetForms) - $this.find('form').each(function() { - this.reset(); - }); - }, config.delay); - }; - - // 브라우저 호환성을 위한 CSS 설정 - $this - .css('-ms-overflow-style', '-ms-autohiding-scrollbar') - .css('-webkit-overflow-scrolling', 'touch'); - - // 링크 클릭 시 패널 숨기기 이벤트 핸들러 - if (config.hideOnClick) { - - $this.find('a') - .css('-webkit-tap-highlight-color', 'rgba(0,0,0,0)'); - - $this - .on('click', 'a', function(event) { - - var $a = $(this), - href = $a.attr('href'), - target = $a.attr('target'); - - // --- ⬇️ 수정된 로직 시작 ⬇️ --- - - // 1. 팝업 링크인지 먼저 확인합니다. - // 링크에 'open-login-popup' 클래스가 있으면 메뉴를 닫고, 이 핸들러의 동작은 여기서 종료합니다. - // (팝업을 여는 동작은 common.js에 있는 다른 이벤트 핸들러가 처리합니다.) - if ($a.hasClass('open-login-popup')) { - $this._hide(); - return; - } - - // 2. 기존의 일반 링크(페이지 이동) 처리 로직은 그대로 둡니다. - if (!href || href == '#' || href == '' || href == '#' + id) - return; - - // --- ⬆️ 수정된 로직 끝 ⬆️ --- - - // Cancel original event. - event.preventDefault(); - event.stopPropagation(); - - // Hide panel. - $this._hide(); - - // Redirect to href. - window.setTimeout(function() { - if (target == '_blank') - window.open(href); - else - window.location.href = href; - }, config.delay + 10); - - }); - } - - // 터치 및 스와이프 이벤트 핸들러 - $this.on('touchstart', function(event) { - $this.touchPosX = event.originalEvent.touches[0].pageX; - $this.touchPosY = event.originalEvent.touches[0].pageY; - }); - $this.on('touchmove', function(event) { - if ($this.touchPosX === null || $this.touchPosY === null) return; - var diffX = $this.touchPosX - event.originalEvent.touches[0].pageX, - diffY = $this.touchPosY - event.originalEvent.touches[0].pageY, - th = $this.outerHeight(), - ts = ($this.get(0).scrollHeight - $this.scrollTop()); - - if (config.hideOnSwipe) { - var result = false, boundary = 20, delta = 50; - switch (config.side) { - case 'left': result = (diffY < boundary && diffY > (-1 * boundary)) && (diffX > delta); break; - case 'right': result = (diffY < boundary && diffY > (-1 * boundary)) && (diffX < (-1 * delta)); break; - case 'top': result = (diffX < boundary && diffX > (-1 * boundary)) && (diffY > delta); break; - case 'bottom': result = (diffX < boundary && diffX > (-1 * boundary)) && (diffY < (-1 * delta)); break; - default: break; - } - if (result) { - $this.touchPosX = null; - $this.touchPosY = null; - $this._hide(); - return false; - } - } - - if (($this.scrollTop() < 0 && diffY < 0) || (ts > (th - 2) && ts < (th + 2) && diffY > 0)) { - event.preventDefault(); - event.stopPropagation(); - } - }); - - // 패널 내부에서 발생하는 이벤트가 상위로 전파되는 것을 방지 - $this.on('click touchend touchstart touchmove', function(event) { - event.stopPropagation(); - }); - - // 패널 ID를 가리키는 링크 클릭 시 패널 숨기기 - $this.on('click', 'a[href="#' + id + '"]', function(event) { - event.preventDefault(); - event.stopPropagation(); - config.target.removeClass(config.visibleClass); - }); - - // body 클릭 시 패널 숨기기 - $body.on('click touchend', function(event) { - $this._hide(event); - }); - - // 패널을 여는 링크(토글)에 대한 이벤트 핸들러 - $body.on('click', 'a[href="#' + id + '"]', function(event) { - event.preventDefault(); - event.stopPropagation(); - config.target.toggleClass(config.visibleClass); - }); - - // ESC 키 누를 시 패널 숨기기 - if (config.hideOnEscape) { - $window.on('keydown', function(event) { - if (event.keyCode == 27) $this._hide(event); - }); - } - - return $this; - }; - - /** - * 구형 브라우저에서 input의 'placeholder' 속성을 지원하기 위한 پلی필(Polyfill)입니다. - * @return {jQuery} jQuery 객체 - */ - $.fn.placeholder = function() { - if (typeof (document.createElement('input')).placeholder != 'undefined') - return $(this); - if (this.length == 0) return $this; - if (this.length > 1) { - for (var i=0; i < this.length; i++) $(this[i]).placeholder(); - return $this; - } - var $this = $(this); - $this.find('input[type=text],textarea').each(function() { - var i = $(this); - if (i.val() == '' || i.val() == i.attr('placeholder')) - i.addClass('polyfill-placeholder').val(i.attr('placeholder')); - }).on('blur', function() { - var i = $(this); - if (i.attr('name').match(/-polyfill-field$/)) return; - if (i.val() == '') - i.addClass('polyfill-placeholder').val(i.attr('placeholder')); - }).on('focus', function() { - var i = $(this); - if (i.attr('name').match(/-polyfill-field$/)) return; - if (i.val() == i.attr('placeholder')) - i.removeClass('polyfill-placeholder').val(''); - }); - $this.find('input[type=password]').each(function() { - var i = $(this); - var x = $($('
').append(i.clone()).remove().html().replace(/type="password"/i, 'type="text"').replace(/type=password/i, 'type=text')); - if (i.attr('id') != '') x.attr('id', i.attr('id') + '-polyfill-field'); - if (i.attr('name') != '') x.attr('name', i.attr('name') + '-polyfill-field'); - x.addClass('polyfill-placeholder').val(x.attr('placeholder')).insertAfter(i); - if (i.val() == '') i.hide(); else x.hide(); - i.on('blur', function(event) { - event.preventDefault(); - var x = i.parent().find('input[name=' + i.attr('name') + '-polyfill-field]'); - if (i.val() == '') { i.hide(); x.show(); } - }); - x.on('focus', function(event) { - event.preventDefault(); - var i = x.parent().find('input[name=' + x.attr('name').replace('-polyfill-field', '') + ']'); - x.hide(); - i.show().focus(); - }).on('keypress', function(event) { - event.preventDefault(); - x.val(''); - }); - }); - $this.on('submit', function() { - $this.find('input[type=text],input[type=password],textarea').each(function(event) { - var i = $(this); - if (i.attr('name').match(/-polyfill-field$/)) i.attr('name', ''); - if (i.val() == i.attr('placeholder')) { - i.removeClass('polyfill-placeholder'); - i.val(''); - } - }); - }).on('reset', function(event) { - event.preventDefault(); - $this.find('select').val($('option:first').val()); - $this.find('input,textarea').each(function() { - var i = $(this), x; - i.removeClass('polyfill-placeholder'); - switch (this.type) { - case 'submit': case 'reset': break; - case 'password': - i.val(i.attr('defaultValue')); - x = i.parent().find('input[name=' + i.attr('name') + '-polyfill-field]'); - if (i.val() == '') { i.hide(); x.show(); } else { i.show(); x.hide(); } - break; - case 'checkbox': case 'radio': i.attr('checked', i.attr('defaultValue')); break; - case 'text': case 'textarea': - i.val(i.attr('defaultValue')); - if (i.val() == '') { i.addClass('polyfill-placeholder'); i.val(i.attr('placeholder')); } - break; - default: i.val(i.attr('defaultValue')); break; - } - }); - }); - return $this; - }; - - /** - * 특정 조건에 따라 요소의 순서를 부모 요소의 맨 앞으로 이동시키거나 원래 위치로 되돌립니다. - * (주로 반응형 레이아웃에서 요소의 위치를 변경할 때 사용됩니다.) - * @param {jQuery} $elements 이동시킬 요소 - * @param {bool} condition true이면 맨 앞으로, false이면 원래 위치로 이동 - */ - $.prioritize = function($elements, condition) { - var key = '__prioritize'; - if (typeof $elements != 'jQuery') $elements = $($elements); - $elements.each(function() { - var $e = $(this), $p, $parent = $e.parent(); - if ($parent.length == 0) return; - if (!$e.data(key)) { - if (!condition) return; - $p = $e.prev(); - if ($p.length == 0) return; - $e.prependTo($parent); - $e.data(key, $p); - } else { - if (condition) return; - $p = $e.data(key); - $e.insertAfter($p); - $e.removeData(key); - } - }); - }; - -})(jQuery); \ No newline at end of file diff --git a/src/main/resources/templates/content/blog/editor.html b/src/main/resources/templates/content/editor.html similarity index 100% rename from src/main/resources/templates/content/blog/editor.html rename to src/main/resources/templates/content/editor.html diff --git a/src/main/resources/templates/content/licenses.html b/src/main/resources/templates/content/licenses.html index e88bcf8..82721ab 100644 --- a/src/main/resources/templates/content/licenses.html +++ b/src/main/resources/templates/content/licenses.html @@ -1,151 +1,1715 @@ - - - - - - - - - - -
-
-
-
-
-
- + + + + + + + +
+
+
+

#lun ##Dependency License Report 2025-09-15 13:34:42 KST

+

Apache 2.0

+

1 Group: com.google.android Name: annotations Version: 4.1.1.4

+
+ +
+

2 Group: com.google.auto Name: auto-common Version: 1.2

+
+ +
+

3 Group: com.google.auto.service Name: auto-service Version: 1.0.1

+
+ +
+

4 Group: com.google.auto.service Name: auto-service-annotations Version: 1.1.1

+
+ +
+

5 Group: com.google.errorprone Name: error_prone_annotations Version: 2.27.0

+
+ +
+

6 Group: io.grpc Name: grpc-api Version: 1.59.0

+
+ +
+

7 Group: io.grpc Name: grpc-context Version: 1.59.0

+
+ +
+

8 Group: io.grpc Name: grpc-core Version: 1.59.0

+
+ +
+

9 Group: io.grpc Name: grpc-netty-shaded Version: 1.59.0

+
+ +
+

10 Group: io.grpc Name: grpc-protobuf Version: 1.59.0

+
+ +
+

11 Group: io.grpc Name: grpc-protobuf-lite Version: 1.59.0

+
+ +
+

12 Group: io.grpc Name: grpc-services Version: 1.59.0

+
+ +
+

13 Group: io.grpc Name: grpc-stub Version: 1.59.0

+
+ +
+

14 Group: io.grpc Name: grpc-util Version: 1.59.0

+
+ +
+

15 Group: io.perfmark Name: perfmark-api Version: 0.26.0

+
+ +
+

16 Group: org.springframework.ai Name: spring-ai-core Version: 1.0.0-M6

+
+ +
+

17 Group: org.springframework.ai Name: spring-ai-ollama Version: 1.0.0-M6

+
+ +
+

18 Group: org.springframework.ai Name: spring-ai-ollama-spring-boot-starter Version: 1.0.0-M6

+
+ +
+

19 Group: org.springframework.ai Name: spring-ai-qdrant-store Version: 1.0.0-M6

+
+ +
+

20 Group: org.springframework.ai Name: spring-ai-qdrant-store-spring-boot-starter Version: 1.0.0-M6

+
+ +
+

21 Group: org.springframework.ai Name: spring-ai-retry Version: 1.0.0-M6

+
+ +
+

22 Group: org.springframework.ai Name: spring-ai-spring-boot-autoconfigure Version: 1.0.0-M6

+
+ +
+

23 Group: org.springframework.retry Name: spring-retry Version: 2.0.9

+
+ +
+

Apache License 2.0

+

24 Group: io.swagger.core.v3 Name: swagger-annotations Version: 2.2.25

+
+ +
+

25 Group: org.javassist Name: javassist Version: 3.29.0-GA

+
+ +
+

Apache License, Version 2.0

+

26 Group: com.fasterxml Name: classmate Version: 1.7.0

+
+ +
+

27 Group: com.fasterxml.jackson.core Name: jackson-core Version: 2.17.2

+
+ +
+

28 Group: com.fasterxml.jackson.core Name: jackson-databind Version: 2.17.2

+
+ +
+

29 Group: com.fasterxml.jackson.datatype Name: jackson-datatype-jdk8 Version: 2.17.2

+
+ +
+

30 Group: com.fasterxml.jackson.datatype Name: jackson-datatype-jsr310 Version: 2.17.2

+
+ +
+

31 Group: com.fasterxml.jackson.module Name: jackson-module-jsonSchema Version: 2.17.2

+
+ +
+

32 Group: com.fasterxml.jackson.module Name: jackson-module-kotlin Version: 2.17.2

+
+ +
+

33 Group: com.fasterxml.jackson.module Name: jackson-module-parameter-names Version: 2.17.2

+
+ +
+

34 Group: com.google.guava Name: guava Version: 33.1.0-jre

+
+ +
+

35 Group: com.google.j2objc Name: j2objc-annotations Version: 2.8

+
+ +
+

36 Group: dev.failsafe Name: failsafe Version: 3.3.2

+
+ +
+

37 Group: io.jsonwebtoken Name: jjwt-api Version: 0.11.5

+
+ +
+

38 Group: io.jsonwebtoken Name: jjwt-impl Version: 0.11.5

+
+ +
+

39 Group: io.jsonwebtoken Name: jjwt-jackson Version: 0.11.5

+
+ +
+

40 Group: io.netty Name: netty-buffer Version: 4.1.113.Final

+
+ +
+

41 Group: io.netty Name: netty-codec Version: 4.1.113.Final

+
+ +
+

42 Group: io.netty Name: netty-codec-dns Version: 4.1.113.Final

+
+ +
+

43 Group: io.netty Name: netty-codec-http Version: 4.1.113.Final

+
+ +
+

44 Group: io.netty Name: netty-codec-http2 Version: 4.1.113.Final

+
+ +
+

45 Group: io.netty Name: netty-codec-socks Version: 4.1.113.Final

+
+ +
+

46 Group: io.netty Name: netty-common Version: 4.1.113.Final

+
+ +
+

47 Group: io.netty Name: netty-handler Version: 4.1.113.Final

+
+ +
+

48 Group: io.netty Name: netty-handler-proxy Version: 4.1.113.Final

+
+ +
+

49 Group: io.netty Name: netty-resolver Version: 4.1.113.Final

+
+ +
+

50 Group: io.netty Name: netty-resolver-dns Version: 4.1.113.Final

+
+ +
+

51 Group: io.netty Name: netty-resolver-dns-classes-macos Version: 4.1.113.Final

+
+ +
+

52 Group: io.netty Name: netty-resolver-dns-native-macos Version: 4.1.113.Final

+
+ +
+

53 Group: io.netty Name: netty-transport Version: 4.1.113.Final

+
+ +
+

54 Group: io.netty Name: netty-transport-classes-epoll Version: 4.1.113.Final

+
+ +
+

55 Group: io.netty Name: netty-transport-native-epoll Version: 4.1.113.Final

+
+ +
+

56 Group: io.netty Name: netty-transport-native-unix-common Version: 4.1.113.Final

+
+ +
+

57 Group: io.projectreactor Name: reactor-core Version: 3.6.10

+
+ +
+

58 Group: net.bytebuddy Name: byte-buddy Version: 1.14.19

+
+ +
+

59 Group: org.apache.commons Name: commons-exec Version: 1.3

+
+ +
+

60 Group: org.apache.tomcat Name: tomcat-annotations-api Version: 10.1.30

+
+ +
+

61 Group: org.apache.tomcat.embed Name: tomcat-embed-core Version: 10.1.30

+
+ +
+

62 Group: org.apache.tomcat.embed Name: tomcat-embed-el Version: 10.1.30

+
+ +
+

63 Group: org.apache.tomcat.embed Name: tomcat-embed-jasper Version: 10.1.30

+
+ +
+

64 Group: org.apache.tomcat.embed Name: tomcat-embed-websocket Version: 10.1.30

+
+ +
+

65 Group: org.sejda.imageio Name: webp-imageio Version: 0.1.6

+
+ +
+

66 Group: org.slf4j Name: jcl-over-slf4j Version: 2.0.16

+
+ +
+

67 Group: org.springframework Name: spring-aop Version: 6.1.13

+
+ +
+

68 Group: org.springframework Name: spring-beans Version: 6.1.13

+
+ +
+

69 Group: org.springframework Name: spring-context Version: 6.1.13

+
+ +
+

70 Group: org.springframework Name: spring-context-support Version: 6.1.13

+
+ +
+

71 Group: org.springframework Name: spring-core Version: 6.1.13

+
+ +
+

72 Group: org.springframework Name: spring-expression Version: 6.1.13

+
+ +
+

73 Group: org.springframework Name: spring-jcl Version: 6.1.13

+
+ +
+

74 Group: org.springframework Name: spring-messaging Version: 6.1.13

+
+ +
+

75 Group: org.springframework Name: spring-tx Version: 6.1.13

+
+ +
+

76 Group: org.springframework Name: spring-web Version: 6.1.13

+
+ +
+

77 Group: org.springframework Name: spring-webflux Version: 6.1.13

+
+ +
+

78 Group: org.springframework Name: spring-webmvc Version: 6.1.13

+
+ +
+

79 Group: org.springframework.boot Name: spring-boot Version: 3.3.4

+
+ +
+

80 Group: org.springframework.boot Name: spring-boot-autoconfigure Version: 3.3.4

+
+ +
+

81 Group: org.springframework.boot Name: spring-boot-starter Version: 3.3.4

+
+ +
+

82 Group: org.springframework.boot Name: spring-boot-starter-data-mongodb-reactive Version: 3.3.4

+
+ +
+

83 Group: org.springframework.boot Name: spring-boot-starter-json Version: 3.3.4

+
+ +
+

84 Group: org.springframework.boot Name: spring-boot-starter-logging Version: 3.3.4

+
+ +
+

85 Group: org.springframework.boot Name: spring-boot-starter-quartz Version: 3.3.4

+
+ +
+

86 Group: org.springframework.boot Name: spring-boot-starter-reactor-netty Version: 3.3.4

+
+ +
+

87 Group: org.springframework.boot Name: spring-boot-starter-security Version: 3.3.4

+
+ +
+

88 Group: org.springframework.boot Name: spring-boot-starter-thymeleaf Version: 3.3.4

+
+ +
+

89 Group: org.springframework.boot Name: spring-boot-starter-tomcat Version: 3.3.4

+
+ +
+

90 Group: org.springframework.boot Name: spring-boot-starter-web Version: 3.3.4

+
+ +
+

91 Group: org.springframework.boot Name: spring-boot-starter-webflux Version: 3.3.4

+
+ +
+

92 Group: org.springframework.data Name: spring-data-commons Version: 3.3.4

+
+ +
+

93 Group: org.springframework.data Name: spring-data-mongodb Version: 4.3.4

+
+ +
+

94 Group: org.springframework.security Name: spring-security-config Version: 6.3.3

+
+ +
+

95 Group: org.springframework.security Name: spring-security-core Version: 6.3.3

+
+ +
+

96 Group: org.springframework.security Name: spring-security-crypto Version: 6.3.3

+
+ +
+

97 Group: org.springframework.security Name: spring-security-web Version: 6.3.3

+
+ +
+

98 Group: org.yaml Name: snakeyaml Version: 2.2

+
+ +
+

Apache-2.0

+

99 Group: com.google.api.grpc Name: proto-google-common-protos Version: 2.22.0

+
+ +
+

100 Group: com.google.code.gson Name: gson Version: 2.11.0

+
+ +
+

101 Group: org.apache.logging.log4j Name: log4j-api Version: 2.23.1

+
+ +
+

102 Group: org.apache.logging.log4j Name: log4j-to-slf4j Version: 2.23.1

+
+ +
+

BSD 2-Clause License

+

103 Group: org.commonmark Name: commonmark Version: 0.18.0

+
+ +
+

BSD licence

+

104 Group: org.antlr Name: antlr-runtime Version: 3.5.3

+
+ +
+

BSD-2-Clause

+

105 Group: org.hdrhistogram Name: HdrHistogram Version: 2.2.2

+
+ +
+

BSD-3-Clause

+

106 Group: com.google.protobuf Name: protobuf-java Version: 3.25.2

+
+ +
+

107 Group: com.google.protobuf Name: protobuf-java-util Version: 3.25.2

+
+ +
+

108 Group: org.antlr Name: antlr4-runtime Version: 4.13.1

+
+ +
+

EPL 2.0

+

109 Group: jakarta.annotation Name: jakarta.annotation-api Version: 2.1.1

+
+ +
+

Eclipse Public License - v 1.0

+

110 Group: ch.qos.logback Name: logback-classic Version: 1.5.8

+
+ +
+

111 Group: ch.qos.logback Name: logback-core Version: 1.5.8

+
+ +
+

Eclipse Public License - v 2.0

+

112 Group: org.eclipse.jdt Name: ecj Version: 3.33.0

+
+ +
+

Eclipse Public License v. 2.0

+

113 Group: jakarta.annotation Name: jakarta.annotation-api Version: 2.1.1

+
+ +
+

Eclipse Public License, Version 1.0

+

114 Group: com.mchange Name: mchange-commons-java Version: 0.2.15

+
+ +
+

GNU General Public License, version 2 with the GNU Classpath Exception

+

115 Group: jakarta.annotation Name: jakarta.annotation-api Version: 2.1.1

+
+ +
+

GNU Lesser General Public License

+

116 Group: ch.qos.logback Name: logback-classic Version: 1.5.8

+
+ +
+

117 Group: ch.qos.logback Name: logback-core Version: 1.5.8

+
+ +
+

GNU Lesser General Public License, Version 2.1

+

118 Group: com.mchange Name: mchange-commons-java Version: 0.2.15

+
+ +
+

GPL2 w/ CPE

+

119 Group: jakarta.annotation Name: jakarta.annotation-api Version: 2.1.1

+
+ +
+

LGPL 2.1

+

120 Group: org.javassist Name: javassist Version: 3.29.0-GA

+
+ +
+

MIT License

+

121 Group: com.knuddels Name: jtokkit Version: 1.1.0

+
+ +
+

122 Group: org.slf4j Name: jcl-over-slf4j Version: 2.0.16

+
+ +
+

123 Group: org.slf4j Name: jul-to-slf4j Version: 2.0.16

+
+ +
+

124 Group: org.slf4j Name: slf4j-api Version: 2.0.16

+
+ +
+

125 Group: org.slf4j Name: slf4j-simple Version: 1.7.25

+
+ +
+

MIT license

+

126 Group: org.codehaus.mojo Name: animal-sniffer-annotations Version: 1.23

+
+ +
+

MIT-0

+

127 Group: org.reactivestreams Name: reactive-streams Version: 1.0.4

+
+ +
+

MPL 1.1

+

128 Group: org.javassist Name: javassist Version: 3.29.0-GA

+
+ +
+

Public Domain, per Creative Commons CC0

+

129 Group: org.hdrhistogram Name: HdrHistogram Version: 2.2.2

+
+ +
+

130 Group: org.latencyutils Name: LatencyUtils Version: 2.0.3

+
+ +
+

The Apache License, Version 2.0

+

131 Group: com.github.victools Name: jsonschema-generator Version: 4.37.0

+
+ +
+

132 Group: com.github.victools Name: jsonschema-module-jackson Version: 4.37.0

+
+ +
+

133 Group: com.github.victools Name: jsonschema-module-swagger-2 Version: 4.37.0

+
+ +
+

134 Group: io.opencensus Name: opencensus-api Version: 0.31.0

+
+ +
+

135 Group: io.opentelemetry Name: opentelemetry-api Version: 1.37.0

+
+ +
+

136 Group: io.opentelemetry Name: opentelemetry-api-incubator Version: 1.37.0-alpha

+
+ +
+

137 Group: io.opentelemetry Name: opentelemetry-context Version: 1.37.0

+
+ +
+

138 Group: io.opentelemetry Name: opentelemetry-exporter-logging Version: 1.37.0

+
+ +
+

139 Group: io.opentelemetry Name: opentelemetry-sdk Version: 1.37.0

+
+ +
+

140 Group: io.opentelemetry Name: opentelemetry-sdk-common Version: 1.37.0

+
+ +
+

141 Group: io.opentelemetry Name: opentelemetry-sdk-extension-autoconfigure Version: 1.37.0

+
+ +
+

142 Group: io.opentelemetry Name: opentelemetry-sdk-extension-autoconfigure-spi Version: 1.37.0

+
+ +
+

143 Group: io.opentelemetry Name: opentelemetry-sdk-logs Version: 1.37.0

+
+ +
+

144 Group: io.opentelemetry Name: opentelemetry-sdk-metrics Version: 1.37.0

+
+ +
+

145 Group: io.opentelemetry Name: opentelemetry-sdk-trace Version: 1.37.0

+
+ +
+

146 Group: io.opentelemetry.semconv Name: opentelemetry-semconv Version: 1.23.1-alpha

+
+ +
+

147 Group: io.qdrant Name: client Version: 1.9.1

+
+ +
+

148 Group: org.jetbrains.kotlin Name: kotlin-reflect Version: 1.9.25

+
+ +
+

149 Group: org.jetbrains.kotlin Name: kotlin-stdlib Version: 1.9.25

+
+ +
+

150 Group: org.jetbrains.kotlin Name: kotlin-stdlib-jdk7 Version: 1.9.25

+
+ +
+

151 Group: org.jetbrains.kotlin Name: kotlin-stdlib-jdk8 Version: 1.9.25

+
+ +
+

152 Group: org.mongodb Name: bson Version: 5.0.1

+
+ +
+

153 Group: org.mongodb Name: bson-record-codec Version: 5.0.1

+
+ +
+

154 Group: org.mongodb Name: mongodb-driver-core Version: 5.0.1

+
+ +
+

155 Group: org.mongodb Name: mongodb-driver-reactivestreams Version: 5.0.1

+
+ +
+

The Apache Software License, Version 2.0

+

156 Group: com.drewnoakes Name: metadata-extractor Version: 2.19.0

+
+ +
+

157 Group: com.fasterxml Name: classmate Version: 1.7.0

+
+ +
+

158 Group: com.fasterxml.jackson.core Name: jackson-annotations Version: 2.17.2

+
+ +
+

159 Group: com.fasterxml.jackson.core Name: jackson-core Version: 2.17.2

+
+ +
+

160 Group: com.fasterxml.jackson.core Name: jackson-databind Version: 2.17.2

+
+ +
+

161 Group: com.fasterxml.jackson.datatype Name: jackson-datatype-jdk8 Version: 2.17.2

+
+ +
+

162 Group: com.fasterxml.jackson.datatype Name: jackson-datatype-jsr310 Version: 2.17.2

+
+ +
+

163 Group: com.fasterxml.jackson.module Name: jackson-module-jsonSchema Version: 2.17.2

+
+ +
+

164 Group: com.fasterxml.jackson.module Name: jackson-module-kotlin Version: 2.17.2

+
+ +
+

165 Group: com.fasterxml.jackson.module Name: jackson-module-parameter-names Version: 2.17.2

+
+ +
+

166 Group: com.google.code.findbugs Name: jsr305 Version: 3.0.2

+
+ +
+

167 Group: com.google.guava Name: failureaccess Version: 1.0.2

+
+ +
+

168 Group: com.google.guava Name: listenablefuture Version: 9999.0-empty-to-avoid-conflict-with-guava

+
+ +
+

169 Group: com.google.maps Name: google-maps-services Version: 2.2.0

+
+ +
+

170 Group: com.squareup.okhttp3 Name: okhttp Version: 4.12.0

+
+ +
+

171 Group: com.squareup.okio Name: okio-jvm Version: 3.6.0

+
+ +
+

172 Group: io.micrometer Name: context-propagation Version: 1.1.1

+
+ +
+

173 Group: io.micrometer Name: micrometer-commons Version: 1.13.4

+
+ +
+

174 Group: io.micrometer Name: micrometer-core Version: 1.13.4

+
+ +
+

175 Group: io.micrometer Name: micrometer-observation Version: 1.13.4

+
+ +
+

176 Group: io.projectreactor.kotlin Name: reactor-kotlin-extensions Version: 1.2.3

+
+ +
+

177 Group: io.projectreactor.netty Name: reactor-netty-core Version: 1.1.22

+
+ +
+

178 Group: io.projectreactor.netty Name: reactor-netty-http Version: 1.1.22

+
+ +
+

179 Group: javax.validation Name: validation-api Version: 1.1.0.Final

+
+ +
+

180 Group: nz.net.ultraq.groovy Name: groovy-extensions Version: 2.1.0

+
+ +
+

181 Group: nz.net.ultraq.thymeleaf Name: thymeleaf-expression-processor Version: 3.2.0

+
+ +
+

182 Group: nz.net.ultraq.thymeleaf Name: thymeleaf-layout-dialect Version: 3.3.0

+
+ +
+

183 Group: ognl Name: ognl Version: 3.3.4

+
+ +
+

184 Group: org.apache.groovy Name: groovy Version: 4.0.23

+
+ +
+

185 Group: org.attoparser Name: attoparser Version: 2.0.7.RELEASE

+
+ +
+

186 Group: org.codehaus.mojo Name: animal-sniffer-annotations Version: 1.23

+
+ +
+

187 Group: org.jetbrains Name: annotations Version: 23.0.0

+
+ +
+

188 Group: org.jetbrains.kotlinx Name: kotlinx-coroutines-core-jvm Version: 1.8.1

+
+ +
+

189 Group: org.jetbrains.kotlinx Name: kotlinx-coroutines-reactive Version: 1.8.1

+
+ +
+

190 Group: org.jetbrains.kotlinx Name: kotlinx-coroutines-reactor Version: 1.8.1

+
+ +
+

191 Group: org.quartz-scheduler Name: quartz Version: 2.3.2

+
+ +
+

192 Group: org.seleniumhq.selenium Name: selenium-api Version: 4.19.1

+
+ +
+

193 Group: org.seleniumhq.selenium Name: selenium-chrome-driver Version: 4.19.1

+
+ +
+

194 Group: org.seleniumhq.selenium Name: selenium-chromium-driver Version: 4.19.1

+
+ +
+

195 Group: org.seleniumhq.selenium Name: selenium-devtools-v112 Version: 4.10.0

+
+ +
+

196 Group: org.seleniumhq.selenium Name: selenium-devtools-v113 Version: 4.10.0

+
+ +
+

197 Group: org.seleniumhq.selenium Name: selenium-devtools-v114 Version: 4.10.0

+
+ +
+

198 Group: org.seleniumhq.selenium Name: selenium-devtools-v85 Version: 4.19.1

+
+ +
+

199 Group: org.seleniumhq.selenium Name: selenium-edge-driver Version: 4.19.1

+
+ +
+

200 Group: org.seleniumhq.selenium Name: selenium-firefox-driver Version: 4.19.1

+
+ +
+

201 Group: org.seleniumhq.selenium Name: selenium-http Version: 4.19.1

+
+ +
+

202 Group: org.seleniumhq.selenium Name: selenium-ie-driver Version: 4.19.1

+
+ +
+

203 Group: org.seleniumhq.selenium Name: selenium-java Version: 4.10.0

+
+ +
+

204 Group: org.seleniumhq.selenium Name: selenium-json Version: 4.19.1

+
+ +
+

205 Group: org.seleniumhq.selenium Name: selenium-manager Version: 4.19.1

+
+ +
+

206 Group: org.seleniumhq.selenium Name: selenium-os Version: 4.19.1

+
+ +
+

207 Group: org.seleniumhq.selenium Name: selenium-remote-driver Version: 4.19.1

+
+ +
+

208 Group: org.seleniumhq.selenium Name: selenium-safari-driver Version: 4.19.1

+
+ +
+

209 Group: org.seleniumhq.selenium Name: selenium-support Version: 4.19.1

+
+ +
+

210 Group: org.thymeleaf Name: thymeleaf Version: 3.1.2.RELEASE

+
+ +
+

211 Group: org.thymeleaf Name: thymeleaf-spring6 Version: 3.1.2.RELEASE

+
+ +
+

212 Group: org.thymeleaf.extras Name: thymeleaf-extras-springsecurity6 Version: 3.1.2.RELEASE

+
+ +
+

213 Group: org.unbescape Name: unbescape Version: 1.1.6.RELEASE

+
+ +
+

The BSD 3-Clause License (BSD3)

+

214 Group: com.adobe.xmp Name: xmpcore Version: 6.1.11

+
+ +
+

The BSD License

+

215 Group: org.antlr Name: ST4 Version: 4.3.4

+
+ +
+

The MIT License

+

216 Group: org.checkerframework Name: checker-qual Version: 3.42.0

+
+ +
+

217 Group: org.jsoup Name: jsoup Version: 1.18.1

+
+ +
+

The MIT License (MIT)

+

218 Group: net.coobird Name: thumbnailator Version: 0.4.14

+
+ +
+

Unknown

+

219 Group: com.fasterxml.jackson Name: jackson-bom Version: 2.17.2

+

220 Group: com.squareup.okio Name: okio Version: 3.6.0

+

221 Group: org.apache.groovy Name: groovy-bom Version: 4.0.23

+

222 Group: org.jetbrains.kotlin Name: kotlin-stdlib-common Version: 1.9.25

+

223 Group: org.jetbrains.kotlinx Name: kotlinx-coroutines-bom Version: 1.8.1

+

224 Group: org.jetbrains.kotlinx Name: kotlinx-coroutines-core Version: 1.8.1

+

225 Group: org.springframework.ai Name: spring-ai-bom Version: 1.0.0-M6

+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/content/blog/posts.html b/src/main/resources/templates/content/posts.html similarity index 81% rename from src/main/resources/templates/content/blog/posts.html rename to src/main/resources/templates/content/posts.html index e703c64..3a31159 100644 --- a/src/main/resources/templates/content/blog/posts.html +++ b/src/main/resources/templates/content/posts.html @@ -23,13 +23,12 @@

- - - - 비공개 - - - + + + 비공개 + + + (읽음: 0) @@ -40,7 +39,9 @@

-
diff --git a/src/main/resources/templates/content/private/where.html b/src/main/resources/templates/content/private/where.html index c56f8e8..3ca89a3 100644 --- a/src/main/resources/templates/content/private/where.html +++ b/src/main/resources/templates/content/private/where.html @@ -5,7 +5,6 @@ xmlns:sec="http://www.thymeleaf.org/extras/spring-security" layout:decorate="~{layout/default_layout}"> -
diff --git a/src/main/resources/templates/content/user/my_info.html b/src/main/resources/templates/content/user/my_info.html new file mode 100644 index 0000000..23e0514 --- /dev/null +++ b/src/main/resources/templates/content/user/my_info.html @@ -0,0 +1,232 @@ + + + + + + + + +
+
+
+

+

가입일:

+
+ +
+ + + + + + + +
+ +
+
+

기본 정보

+ +
+
+ +
+
+ +
+
+ +
+
+
    +
  • + 댓글 내용 + 원문보기 +
  • +
  • 작성한 댓글이 없습니다.
  • +
+
+
+ +
+
+

권한 요청

+
    +
  • + + () + +
    + + +
    +
  • +
  • 새로운 권한 요청이 없습니다.
  • +
+
+
+

전체 회원

+
    +
  • + + - 현재 권한: + +
  • +
+
+
+ +
+
+

최신 글 관리

+ +
+
+
+
+ + +
+ \ No newline at end of file diff --git a/src/main/resources/templates/content/blog/viewer.html b/src/main/resources/templates/content/viewer.html similarity index 91% rename from src/main/resources/templates/content/blog/viewer.html rename to src/main/resources/templates/content/viewer.html index 83f43a5..f779810 100644 --- a/src/main/resources/templates/content/blog/viewer.html +++ b/src/main/resources/templates/content/viewer.html @@ -17,7 +17,14 @@
-
+

게시물 제목이 여기에 표시됩니다 diff --git a/src/main/resources/templates/fragments/footer.html b/src/main/resources/templates/fragments/footer.html index e66b879..671214e 100644 --- a/src/main/resources/templates/fragments/footer.html +++ b/src/main/resources/templates/fragments/footer.html @@ -15,29 +15,17 @@

Rank of Views

Recent of Posts

-

Get In Touch

+

SEND TO ME(TELEGRAM BOT)

diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index 2606aa0..38315e7 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -43,19 +43,23 @@ -
  • Licenses
  • + + +
  • 내 정보
  • +
  • - 로그아웃 + Logout
  • +
  • Licenses
  • diff --git a/src/main/resources/templates/fragments/includes.html b/src/main/resources/templates/fragments/includes.html index 3994a2e..3e2da66 100644 --- a/src/main/resources/templates/fragments/includes.html +++ b/src/main/resources/templates/fragments/includes.html @@ -12,34 +12,33 @@ - - - - + \ No newline at end of file