...
This commit is contained in:
parent
b10d3223fd
commit
efc03bac91
@ -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/**",
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}")
|
||||
|
||||
234
src/main/resources/templates/content/slideshow.html
Normal file
234
src/main/resources/templates/content/slideshow.html
Normal 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>
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user