This commit is contained in:
lunaticbum 2025-12-29 17:18:17 +09:00
parent b10d3223fd
commit efc03bac91
5 changed files with 378 additions and 2 deletions

View File

@ -112,6 +112,7 @@ class SecurityConfig(
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/api/synology/**").permitAll()
.requestMatchers(HttpMethod.GET,"/api/feed").permitAll()
.requestMatchers("/api/stock/**").permitAll()
.requestMatchers("/api/ranks/**").permitAll()
@ -167,6 +168,7 @@ class SecurityConfig(
.requestMatchers(
"/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**"
).permitAll()
.requestMatchers("/api/synology/**").permitAll()
.requestMatchers(HttpMethod.GET,"/stock/**").permitAll()
// 2. 공개 GET API 및 페이지 = permitAll
.requestMatchers(HttpMethod.GET,
@ -182,12 +184,14 @@ class SecurityConfig(
"/puzzle/images/**",
"/bums/face.bs", // [추가] 사이트 소개 페이지
"/bookmarks/**", // [추가] 북마크 목록 페이지
"/ads.txt"
"/ads.txt",
"/slideshow"
).permitAll()
// 3. 공개 POST API = permitAll
.requestMatchers(HttpMethod.POST,
"/user/login.bjx", "/user/joinUser.bjx",
"/user/login.bjx",
"/user/joinUser.bjx",
"/api/ranks/submit",
"/bums/save/loc.api",
"/puzzle/**",

View File

@ -0,0 +1,130 @@
package kr.lunaticbum.back.lun.controllers.api
import io.netty.handler.ssl.SslContextBuilder
import io.netty.handler.ssl.util.InsecureTrustManagerFactory
import kotlinx.coroutines.reactor.awaitSingle
import org.slf4j.LoggerFactory
import org.springframework.core.io.buffer.DataBuffer
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.web.bind.annotation.*
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
import reactor.core.publisher.Flux
import reactor.netty.http.client.HttpClient
@RestController
@RequestMapping("/api/synology")
class SynologyProxyController {
private val logger = LoggerFactory.getLogger(SynologyProxyController::class.java)
// SSL 검증 무시 설정 (HTTPS 사용 시 필요)
private val httpClient = HttpClient.create()
.secure { t -> t.sslContext(
SslContextBuilder.forClient()
.trustManager(InsecureTrustManagerFactory.INSTANCE)
.build()
)}
// [수정됨] WebClient 생성 시 버퍼 크기 제한 해제 (256KB -> 20MB)
private val webClient = WebClient.builder()
.clientConnector(ReactorClientHttpConnector(httpClient))
.codecs { configurer ->
configurer.defaultCodecs().maxInMemorySize(20 * 1024 * 1024) // 20MB로 증설
}
.build()
private fun buildBaseUrl(nasAddress: String): String {
if (nasAddress.startsWith("http://") || nasAddress.startsWith("https://")) {
return nasAddress
}
return if (nasAddress.contains(":5000")) "http://$nasAddress" else "https://$nasAddress"
}
@PostMapping("/login")
suspend fun login(@RequestBody payload: Map<String, Any>): String {
try {
val nasAddress = payload["nasAddress"].toString()
val username = payload["username"].toString()
val password = payload["password"].toString()
val baseUrl = buildBaseUrl(nasAddress)
val url = "$baseUrl/webapi/auth.cgi?api=SYNO.API.Auth&version=7&method=login&account=$username&passwd=$password&session=SynologyPhotos&format=sid"
logger.info("Login Request URL: $url")
return webClient.get().uri(url).retrieve().awaitBody()
} catch (e: Exception) {
logger.error("Login Error: ", e)
throw e
}
}
@PostMapping("/list")
suspend fun list(@RequestBody payload: Map<String, Any>): String {
try {
val nasAddress = payload["nasAddress"].toString()
val sid = payload["sid"].toString()
val offset = payload["offset"].toString()
val limit = payload["limit"].toString()
val baseUrl = buildBaseUrl(nasAddress)
val url = "$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Browse.Item&version=1&method=list&limit=$limit&offset=$offset&_sid=$sid"
logger.info("List Request URL: $url")
return webClient.get().uri(url).retrieve().awaitBody()
} catch (e: Exception) {
logger.error("List Error: ", e)
throw e
}
}
@GetMapping("/image")
suspend fun getImage(
@RequestParam nasAddress: String,
@RequestParam sid: String,
@RequestParam id: String
): ResponseEntity<ByteArray> {
val baseUrl = buildBaseUrl(nasAddress)
// [핵심] 문자열 더하기 대신 UriComponentsBuilder 사용
// Spring이 '[' 문자를 자동으로 알맞게(%5B) 인코딩해줍니다. (이중 인코딩 방지)
val uri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
.path("/webapi/entry.cgi")
.queryParam("api", "SYNO.Foto.Download")
.queryParam("version", "1")
.queryParam("method", "download")
.queryParam("force_download", "true")
.queryParam("item_id", "[$id]") // 여기에 대괄호 []를 직접 넣습니다.
.queryParam("_sid", sid)
.build()
.toUri()
try {
val response = webClient.get()
.uri(uri) // String url 대신 URI 객체를 전달
.accept(MediaType.IMAGE_JPEG, MediaType.IMAGE_PNG, MediaType.ALL)
.retrieve()
.toEntity(ByteArray::class.java)
.awaitSingle()
val contentType = response.headers.contentType
// 에러 체크
if (contentType != null && !contentType.toString().startsWith("image")) {
val errorBody = String(response.body ?: ByteArray(0))
logger.error("NAS returned non-image content ($contentType): $errorBody")
logger.error("Requested URI: $uri") // 디버깅을 위해 요청한 URI도 출력
}
return ResponseEntity.ok()
.contentType(contentType ?: MediaType.IMAGE_JPEG)
.body(response.body)
} catch (e: Exception) {
logger.error("Image Fetch Error: ", e)
return ResponseEntity.notFound().build()
}
}
}

View File

@ -246,6 +246,11 @@ class PostViewController(
return vm
}
@GetMapping("/slideshow")
fun slideshowPage(): ResultMV {
return ResultMV("content/slideshow") // templates/content/slideshow.html
}
// --- [핵심 수정] 뷰어 (북마크 포함) ---
// [핵심 수정] 뷰어 메서드
@GetMapping("/blog/viewer/{id}")

View File

@ -0,0 +1,234 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BUM's NAS Slideshow</title>
<style>
/* 기본 레이아웃 */
body, html { margin: 0; padding: 0; width: 100%; height: 100%; background-color: #000; color: #fff; font-family: sans-serif; overflow: hidden; }
/* 나가기 버튼 */
.exit-btn { position: absolute; top: 20px; right: 20px; z-index: 100; color: rgba(255,255,255,0.7); text-decoration: none; background: rgba(0,0,0,0.5); padding: 8px 15px; border-radius: 30px; font-size: 0.9em; transition: 0.3s; }
.exit-btn:hover { background: rgba(228, 76, 101, 0.8); color: white; }
/* 로그인 폼 (중앙 정렬) */
#login-container {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
z-index: 50; background: #000;
}
#login-form { display: flex; flex-direction: column; gap: 15px; padding: 40px; background: #222; border-radius: 12px; width: 320px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
#login-form input { padding: 12px; border: 1px solid #444; background: #333; color: #fff; border-radius: 6px; outline: none; }
#login-form input:focus { border-color: #e44c65; }
#login-form button { padding: 12px; background: #e44c65; border: none; color: white; cursor: pointer; border-radius: 6px; font-weight: bold; font-size: 1em; transition: 0.2s; }
#login-form button:hover { background: #d03d56; }
/* 사진 컨테이너 (전체 화면) */
#photo-container {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 1;
/* 이미지가 겹쳐지도록 relative 설정 */
position: relative;
}
/* 개별 슬라이드 이미지 스타일 (겹치기용) */
.slide-image {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
object-fit: contain; /* 비율 유지하며 화면에 맞춤 */
opacity: 0; /* 기본적으로 투명 */
transition: opacity 1.5s ease-in-out; /* 부드러운 전환 효과 */
will-change: opacity; /* 성능 최적화 */
}
.slide-image.active {
opacity: 1; /* 활성화된 이미지만 보임 */
}
/* 하단 컨트롤러 (속도 조절) */
#controls {
position: absolute; bottom: 0; left: 0; width: 100%;
padding: 20px;
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
z-index: 20;
display: none; /* 슬라이드 시작 전엔 숨김 */
justify-content: center;
align-items: center;
gap: 15px;
opacity: 0; transition: opacity 0.5s;
}
/* 마우스 올리면 컨트롤러 보이기 */
body:hover #controls { opacity: 1; }
/* 슬라이더 스타일 */
input[type=range] {
-webkit-appearance: none; width: 300px; height: 6px; background: rgba(255,255,255,0.3); border-radius: 3px; outline: none;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #e44c65; cursor: pointer;
}
</style>
</head>
<body>
<a href="/" class="exit-btn">✕ Close</a>
<div id="login-container">
<div id="login-form">
<h2 style="color:#e44c65; text-align:center; margin:0 0 20px 0;">Synology Login</h2>
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
<input type="text" id="nas-address" placeholder="NAS IP:Port (ex: 192.168.0.5:5000)">
<input type="text" id="username" placeholder="ID">
<input type="password" id="password" placeholder="Password">
<button onclick="loginAndStart()">Start Slideshow</button>
<p id="message" style="color: #ffdddd; text-align: center; font-size: 0.9em; margin-top: 10px; min-height: 1.2em;"></p>
</div>
</div>
<div id="photo-container">
</div>
<div id="controls">
<span style="font-size: 0.9em; color: #ccc;">Speed: <span id="speed-display">5</span> sec</span>
<input type="range" id="speed-slider" min="3" max="30" value="5" step="1" oninput="updateSpeed(this.value)">
</div>
<script>
// 전역 변수
let sid = '';
let nasAddress = '';
const allPhotoIds = [];
let slideshowTimeout; // setTimeout ID 저장
let currentSpeed = 5000; // 기본 5초
const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
// UI 유틸리티
function showMessage(msg) { document.getElementById('message').innerText = msg; }
function updateSpeed(val) {
currentSpeed = val * 1000;
document.getElementById('speed-display').innerText = val;
}
// 1. 로그인 및 목록 가져오기
async function loginAndStart() {
nasAddress = document.getElementById('nas-address').value.trim();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
if (!nasAddress || !username || !password) {
showMessage('모든 정보를 입력해주세요.'); return;
}
showMessage('로그인 중...');
try {
// 로그인 요청
const response = await fetch('/api/synology/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
body: JSON.stringify({ nasAddress, username, password })
});
const loginData = await response.json();
if (loginData.success && loginData.data.sid) {
sid = loginData.data.sid;
showMessage('목록 가져오는 중...');
await fetchAllPhotos(0);
} else {
showMessage('로그인 실패 (정보 확인 필요)');
}
} catch (error) {
console.error(error);
showMessage('서버 통신 오류');
}
}
// 2. 전체 사진 ID 수집 (재귀)
async function fetchAllPhotos(offset) {
const limit = 5000;
try {
const response = await fetch('/api/synology/list', {
method: 'POST',
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
body: JSON.stringify({ nasAddress, sid, offset, limit })
});
const data = await response.json();
if (data.success) {
data.data.list.forEach(item => allPhotoIds.push(item.id));
const total = data.data.total;
if (offset + limit < total) {
showMessage(`${allPhotoIds.length} / ${total} 장 로딩...`);
await fetchAllPhotos(offset + limit);
} else {
// 로딩 완료 -> UI 전환 및 슬라이드 시작
startSlideshow();
}
}
} catch (error) { showMessage('목록 불러오기 실패'); }
}
// 3. UI 전환
function startSlideshow() {
document.getElementById('login-container').style.display = 'none'; // 로그인창 숨김
document.getElementById('controls').style.display = 'flex'; // 컨트롤러 보이기
// 첫 사진 로드
loadNextPhoto();
}
// 4. [핵심] 부드러운 이미지 교체 로직
function loadNextPhoto() {
if (allPhotoIds.length === 0) return;
// 랜덤 ID 선택
const photoId = allPhotoIds[Math.floor(Math.random() * allPhotoIds.length)];
const imgUrl = `/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${photoId}`;
// 새 이미지 태그 생성
const newImg = document.createElement('img');
newImg.src = imgUrl;
newImg.className = 'slide-image'; // opacity: 0 상태
// 이미지가 로드되면 화면에 붙이고 페이드인
newImg.onload = () => {
const container = document.getElementById('photo-container');
container.appendChild(newImg);
// 브라우저가 DOM 추가를 인식할 시간을 줌 (약간의 딜레이)
requestAnimationFrame(() => {
newImg.classList.add('active'); // opacity: 1로 변경 (CSS transition 발동)
});
// 기존 이미지 정리 (메모리 누수 방지)
const oldImages = container.querySelectorAll('img');
if (oldImages.length > 1) {
// 새 이미지가 완전히 나타난 후(1.5초 뒤) 이전 이미지 삭제
setTimeout(() => {
// 가장 오래된 이미지부터 삭제 (마지막에 추가된 게 현재 이미지)
for(let i = 0; i < oldImages.length - 1; i++) {
container.removeChild(oldImages[i]);
}
}, 1500); // CSS transition 시간과 일치
}
// 다음 슬라이드 예약 (재귀 호출)
// setInterval 대신 setTimeout을 써야 속도 변경이 즉시 반영됨
clearTimeout(slideshowTimeout);
slideshowTimeout = setTimeout(loadNextPhoto, currentSpeed);
};
// 이미지 로드 실패 시 재시도
newImg.onerror = () => {
console.log("Image load failed, retrying...");
setTimeout(loadNextPhoto, 1000);
};
}
</script>
</body>
</html>

View File

@ -14,6 +14,8 @@
<nav id="nav">
<ul>
<li id="menu_home" ><a th:href="@{/}">Home</a></li>
<li id="slideshow" ><a th:href="@{/slideshow}">slideshow</a></li>
<li id="menu_stock">
<a href="#">Stock</a>
<ul>
@ -22,6 +24,7 @@
<li><a href="/stock/market">시장 지표</a></li>
</ul>
</li>
<!-- <li id="menu_bookmarks"><a href="/bookmarks">Bookmarks</a></li>-->
<li id="menu_playz">
<a href="#">Game</a>