This commit is contained in:
102
backend/main.py
102
backend/main.py
@@ -501,3 +501,105 @@ def delete_reply(reply_id: int, token=Depends(verify_token), conn=Depends(get_db
|
||||
cur.execute("DELETE FROM board_replies WHERE id = %s", (reply_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
# ─── 공지사항 ──────────────────────────────────────────────
|
||||
class NoticeCreate(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
|
||||
class NoticeReplyCreate(BaseModel):
|
||||
content: str
|
||||
|
||||
def init_notice_db():
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS notice_posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
author_name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS notice_replies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
post_id INTEGER REFERENCES notice_posts(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
author_name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_notice():
|
||||
import time
|
||||
for _ in range(10):
|
||||
try:
|
||||
init_notice_db()
|
||||
print("Notice DB initialized")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Notice DB not ready... {e}")
|
||||
time.sleep(3)
|
||||
|
||||
@app.get("/api/notice/posts")
|
||||
def list_notice_posts(token=Depends(verify_token), conn=Depends(get_db)):
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT id, title, author_name, created_at FROM notice_posts ORDER BY created_at DESC")
|
||||
return cur.fetchall()
|
||||
|
||||
@app.post("/api/notice/posts")
|
||||
def create_notice_post(data: NoticeCreate, token=Depends(require_admin), conn=Depends(get_db)):
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"INSERT INTO notice_posts (title, content, author_id, author_name) VALUES (%s, %s, %s, %s) RETURNING *",
|
||||
(data.title, data.content, int(token["sub"]), token["username"])
|
||||
)
|
||||
conn.commit()
|
||||
return cur.fetchone()
|
||||
|
||||
@app.get("/api/notice/posts/{post_id}")
|
||||
def get_notice_post(post_id: int, token=Depends(verify_token), conn=Depends(get_db)):
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT * FROM notice_posts WHERE id = %s", (post_id,))
|
||||
post = cur.fetchone()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
cur.execute("SELECT * FROM notice_replies WHERE post_id = %s ORDER BY created_at ASC", (post_id,))
|
||||
replies = cur.fetchall()
|
||||
return {"post": post, "replies": replies}
|
||||
|
||||
@app.delete("/api/notice/posts/{post_id}")
|
||||
def delete_notice_post(post_id: int, token=Depends(require_admin), conn=Depends(get_db)):
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM notice_posts WHERE id = %s", (post_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/notice/posts/{post_id}/replies")
|
||||
def create_notice_reply(post_id: int, data: NoticeReplyCreate, token=Depends(verify_token), conn=Depends(get_db)):
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"INSERT INTO notice_replies (post_id, content, author_id, author_name) VALUES (%s, %s, %s, %s) RETURNING *",
|
||||
(post_id, data.content, int(token["sub"]), token["username"])
|
||||
)
|
||||
conn.commit()
|
||||
return cur.fetchone()
|
||||
|
||||
@app.delete("/api/notice/replies/{reply_id}")
|
||||
def delete_notice_reply(reply_id: int, token=Depends(verify_token), conn=Depends(get_db)):
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT * FROM notice_replies WHERE id = %s", (reply_id,))
|
||||
reply = cur.fetchone()
|
||||
if not reply:
|
||||
raise HTTPException(status_code=404, detail="Reply not found")
|
||||
if not token.get("is_admin") and reply["author_id"] != int(token["sub"]):
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
cur.execute("DELETE FROM notice_replies WHERE id = %s", (reply_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
</header>
|
||||
<nav id="main-nav">
|
||||
<button class="nav-btn active" onclick="showPage('my-pages')">MY Page</button>
|
||||
<button class="nav-btn" onclick="showPage('notice')">📢 공지</button>
|
||||
<button class="nav-btn" onclick="showPage('board')">📋 관리자 요청</button>
|
||||
<button class="nav-btn admin-only" onclick="showPage('admin-pages')" style="display:none">⚙️ 페이지 관리</button>
|
||||
<button class="nav-btn admin-only" onclick="showPage('admin-users')" style="display:none">👥 사용자 관리</button>
|
||||
@@ -229,6 +230,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notice List -->
|
||||
<div id="page-notice" class="page">
|
||||
<div class="board-header">
|
||||
<div><h2>📢 공지사항</h2><p style="color:var(--muted);font-size:.875rem;margin-top:2px">관리자가 등록한 공지사항입니다.</p></div>
|
||||
<button id="notice-write-btn" class="btn btn-sm" onclick="showNoticeWrite()" style="display:none">공지 작성</button>
|
||||
</div>
|
||||
<table class="board-table">
|
||||
<thead><tr><th class="num">번호</th><th>제목</th><th class="author">작성자</th><th class="date">작성일</th></tr></thead>
|
||||
<tbody id="notice-list-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Notice Write -->
|
||||
<div id="page-notice-write" class="page">
|
||||
<div class="page-header"><div><h2>공지 작성</h2></div></div>
|
||||
<div class="write-form">
|
||||
<input type="text" id="notice-write-title" placeholder="제목을 입력하세요"/>
|
||||
<textarea id="notice-write-content" placeholder="내용을 입력하세요"></textarea>
|
||||
<div class="write-form-footer">
|
||||
<button class="btn btn-sm btn-gray" onclick="showPage('notice')">취소</button>
|
||||
<button class="btn btn-sm" onclick="submitNotice()">등록</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notice Detail -->
|
||||
<div id="page-notice-detail" class="page">
|
||||
<div class="page-header" style="margin-bottom:16px">
|
||||
<button class="btn btn-sm btn-gray" onclick="showPage('notice')">← 목록으로</button>
|
||||
</div>
|
||||
<div class="post-detail">
|
||||
<div class="post-header">
|
||||
<h2 id="notice-detail-title"></h2>
|
||||
<div class="post-meta">
|
||||
<span>✍️ <strong id="notice-detail-author"></strong></span>
|
||||
<span>🕐 <span id="notice-detail-date"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-body" id="notice-detail-body"></div>
|
||||
<div class="post-actions" id="notice-detail-actions"></div>
|
||||
<div class="replies-section">
|
||||
<div class="replies-title">💬 댓글</div>
|
||||
<div id="notice-replies-list"></div>
|
||||
<div class="reply-form">
|
||||
<textarea id="notice-reply-input" placeholder="댓글을 입력하세요..."></textarea>
|
||||
<div class="reply-form-footer">
|
||||
<button class="btn btn-sm" onclick="submitNoticeReply()">댓글 등록</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Board List -->
|
||||
<div id="page-board" class="page">
|
||||
<div class="board-header">
|
||||
@@ -392,6 +446,7 @@ function showPage(name){
|
||||
if(b.getAttribute('onclick')&&b.getAttribute('onclick').includes("'"+name+"'"))b.classList.add('active');
|
||||
});
|
||||
if(name==='my-pages')loadMyPages();
|
||||
if(name==='notice')loadNotice();
|
||||
if(name==='board')loadBoard();
|
||||
if(name==='admin-pages')loadAdminPages();
|
||||
if(name==='admin-users')loadAdminUsers();
|
||||
@@ -521,6 +576,105 @@ function getFaviconUrl(url){
|
||||
}catch{return '';}
|
||||
}
|
||||
|
||||
// ── Notice ───────────────────────────────────────────────
|
||||
let currentNoticeId=null;
|
||||
|
||||
async function loadNotice(){
|
||||
// 관리자면 작성 버튼 표시
|
||||
const btn=document.getElementById('notice-write-btn');
|
||||
if(btn)btn.style.display=currentUser&¤tUser.is_admin?'inline-block':'none';
|
||||
|
||||
const posts=await apiFetch('/notice/posts');
|
||||
const tbody=document.getElementById('notice-list-body');
|
||||
if(!posts||posts.length===0){
|
||||
tbody.innerHTML='<tr><td colspan="4" style="text-align:center;padding:40px;color:var(--muted)">등록된 공지가 없습니다.</td></tr>';return;
|
||||
}
|
||||
tbody.innerHTML=posts.map((p,i)=>`
|
||||
<tr onclick="openNotice(${p.id})">
|
||||
<td class="num">${posts.length-i}</td>
|
||||
<td class="title-cell">📢 ${escHtml(p.title)}</td>
|
||||
<td class="author">${escHtml(p.author_name)}</td>
|
||||
<td class="date">${fmtDate(p.created_at)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function showNoticeWrite(){
|
||||
document.getElementById('notice-write-title').value='';
|
||||
document.getElementById('notice-write-content').value='';
|
||||
showPage('notice-write');
|
||||
}
|
||||
|
||||
async function submitNotice(){
|
||||
const title=document.getElementById('notice-write-title').value.trim();
|
||||
const content=document.getElementById('notice-write-content').value.trim();
|
||||
if(!title){alert('제목을 입력해주세요.');return;}
|
||||
if(!content){alert('내용을 입력해주세요.');return;}
|
||||
try{
|
||||
await apiFetch('/notice/posts',{method:'POST',body:JSON.stringify({title,content})});
|
||||
showPage('notice');
|
||||
}catch(e){alert(e.message);}
|
||||
}
|
||||
|
||||
async function openNotice(id){
|
||||
currentNoticeId=id;
|
||||
const data=await apiFetch('/notice/posts/'+id);
|
||||
const p=data.post;
|
||||
document.getElementById('notice-detail-title').textContent=p.title;
|
||||
document.getElementById('notice-detail-author').textContent=p.author_name;
|
||||
document.getElementById('notice-detail-date').textContent=fmtDate(p.created_at);
|
||||
document.getElementById('notice-detail-body').textContent=p.content;
|
||||
document.getElementById('notice-reply-input').value='';
|
||||
const actions=document.getElementById('notice-detail-actions');
|
||||
actions.innerHTML=currentUser&¤tUser.is_admin
|
||||
?`<button class="btn btn-sm btn-danger" onclick="deleteNotice(${p.id})">공지 삭제</button>`:'';
|
||||
renderNoticeReplies(data.replies);
|
||||
showPage('notice-detail');
|
||||
}
|
||||
|
||||
function renderNoticeReplies(replies){
|
||||
const el=document.getElementById('notice-replies-list');
|
||||
if(!replies||replies.length===0){el.innerHTML='<p style="color:var(--muted);font-size:.85rem;padding:8px 0">아직 댓글이 없습니다.</p>';return;}
|
||||
el.innerHTML=replies.map(r=>{
|
||||
const canDel=currentUser&&(currentUser.is_admin||(r.author_name===currentUser.username));
|
||||
return `<div class="reply-item">
|
||||
<div class="reply-meta">
|
||||
<span class="reply-author">💬 ${escHtml(r.author_name)}</span>
|
||||
<span style="display:flex;align-items:center;gap:8px">
|
||||
<span class="reply-date">${fmtDate(r.created_at)}</span>
|
||||
${canDel?`<button class="reply-del" onclick="deleteNoticeReply(${r.id})">삭제</button>`:''}
|
||||
</span>
|
||||
</div>
|
||||
<div class="reply-content">${escHtml(r.content)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function submitNoticeReply(){
|
||||
const content=document.getElementById('notice-reply-input').value.trim();
|
||||
if(!content){alert('댓글 내용을 입력해주세요.');return;}
|
||||
try{
|
||||
await apiFetch('/notice/posts/'+currentNoticeId+'/replies',{method:'POST',body:JSON.stringify({content})});
|
||||
document.getElementById('notice-reply-input').value='';
|
||||
const data=await apiFetch('/notice/posts/'+currentNoticeId);
|
||||
renderNoticeReplies(data.replies);
|
||||
}catch(e){alert(e.message);}
|
||||
}
|
||||
|
||||
async function deleteNotice(id){
|
||||
if(!confirm('공지를 삭제하시겠습니까?'))return;
|
||||
try{await apiFetch('/notice/posts/'+id,{method:'DELETE'});showPage('notice');}
|
||||
catch(e){alert(e.message);}
|
||||
}
|
||||
|
||||
async function deleteNoticeReply(id){
|
||||
if(!confirm('댓글을 삭제하시겠습니까?'))return;
|
||||
try{
|
||||
await apiFetch('/notice/replies/'+id,{method:'DELETE'});
|
||||
const data=await apiFetch('/notice/posts/'+currentNoticeId);
|
||||
renderNoticeReplies(data.replies);
|
||||
}catch(e){alert(e.message);}
|
||||
}
|
||||
|
||||
// ── Board ─────────────────────────────────────────────────
|
||||
async function loadBoard(){
|
||||
const posts=await apiFetch('/board/posts');
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# IP당 로그인 요청 제한 (분당 5회)
|
||||
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
@@ -7,6 +10,19 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 로그인 API - rate limiting 적용
|
||||
location /api/auth/login {
|
||||
limit_req zone=login_limit burst=3 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/api/auth/login;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
Reference in New Issue
Block a user