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,))
|
cur.execute("DELETE FROM board_replies WHERE id = %s", (reply_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True}
|
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>
|
</header>
|
||||||
<nav id="main-nav">
|
<nav id="main-nav">
|
||||||
<button class="nav-btn active" onclick="showPage('my-pages')">MY Page</button>
|
<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" 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-pages')" style="display:none">⚙️ 페이지 관리</button>
|
||||||
<button class="nav-btn admin-only" onclick="showPage('admin-users')" style="display:none">👥 사용자 관리</button>
|
<button class="nav-btn admin-only" onclick="showPage('admin-users')" style="display:none">👥 사용자 관리</button>
|
||||||
@@ -229,6 +230,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Board List -->
|
||||||
<div id="page-board" class="page">
|
<div id="page-board" class="page">
|
||||||
<div class="board-header">
|
<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(b.getAttribute('onclick')&&b.getAttribute('onclick').includes("'"+name+"'"))b.classList.add('active');
|
||||||
});
|
});
|
||||||
if(name==='my-pages')loadMyPages();
|
if(name==='my-pages')loadMyPages();
|
||||||
|
if(name==='notice')loadNotice();
|
||||||
if(name==='board')loadBoard();
|
if(name==='board')loadBoard();
|
||||||
if(name==='admin-pages')loadAdminPages();
|
if(name==='admin-pages')loadAdminPages();
|
||||||
if(name==='admin-users')loadAdminUsers();
|
if(name==='admin-users')loadAdminUsers();
|
||||||
@@ -521,6 +576,105 @@ function getFaviconUrl(url){
|
|||||||
}catch{return '';}
|
}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 ─────────────────────────────────────────────────
|
// ── Board ─────────────────────────────────────────────────
|
||||||
async function loadBoard(){
|
async function loadBoard(){
|
||||||
const posts=await apiFetch('/board/posts');
|
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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
@@ -7,6 +10,19 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
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/ {
|
location /api/ {
|
||||||
proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/api/;
|
proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
Reference in New Issue
Block a user