mirror of
https://github.com/sotam0316/brain_dogfood.git
synced 2026-04-25 03:48:38 +09:00
Fix: JSON parsing error on session expiry and enhance API error handling
This commit is contained in:
@@ -34,8 +34,21 @@ def create_app():
|
|||||||
|
|
||||||
@app.errorhandler(403)
|
@app.errorhandler(403)
|
||||||
def forbidden(e):
|
def forbidden(e):
|
||||||
|
if request.path.startswith('/api/'):
|
||||||
|
return jsonify({'error': 'Forbidden', 'message': 'Suspicious activity detected'}), 403
|
||||||
return "Forbidden: Suspicious activity detected. Your IP has been logged.", 403
|
return "Forbidden: Suspicious activity detected. Your IP has been logged.", 403
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def handle_exception(e):
|
||||||
|
# API 요청인 경우 항상 JSON 반환
|
||||||
|
if request.path.startswith('/api/'):
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
if isinstance(e, HTTPException):
|
||||||
|
return jsonify({'error': e.name, 'message': e.description}), e.code
|
||||||
|
return jsonify({'error': 'Internal Server Error', 'message': str(e)}), 500
|
||||||
|
# 일반 요청은 Flask 기본 처리 (또는 커스텀 HTML 에러 페이지)
|
||||||
|
return e
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def unified_logger():
|
def unified_logger():
|
||||||
# 클라이언트 IP (Cloudflare 등을 거칠 경우 X-Forwarded-For 확인)
|
# 클라이언트 IP (Cloudflare 등을 거칠 경우 X-Forwarded-For 확인)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ def login_required(view):
|
|||||||
def wrapped_view(**kwargs):
|
def wrapped_view(**kwargs):
|
||||||
# app/routes/auth.py의 세션 키와 일치시킴 (logged_in)
|
# app/routes/auth.py의 세션 키와 일치시킴 (logged_in)
|
||||||
if session.get('logged_in') is None:
|
if session.get('logged_in') is None:
|
||||||
|
if request.path.startswith('/api/'):
|
||||||
|
return jsonify({'error': 'Unauthorized', 'message': 'Session expired or not logged in'}), 401
|
||||||
return redirect(url_for('main.login_page'))
|
return redirect(url_for('main.login_page'))
|
||||||
return view(**kwargs)
|
return view(**kwargs)
|
||||||
return wrapped_view
|
return wrapped_view
|
||||||
|
|||||||
+86
-82
@@ -176,40 +176,40 @@ def get_heatmap_stats():
|
|||||||
@memo_bp.route('/api/memos', methods=['POST'])
|
@memo_bp.route('/api/memos', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def create_memo():
|
def create_memo():
|
||||||
data = request.json
|
|
||||||
title = data.get('title', '').strip()
|
|
||||||
content = data.get('content', '').strip()
|
|
||||||
color = data.get('color', '#2c3e50')
|
|
||||||
is_pinned = 1 if data.get('is_pinned') else 0
|
|
||||||
status = data.get('status', 'active').strip()
|
|
||||||
group_name = data.get('group_name', GROUP_DEFAULT).strip()
|
|
||||||
user_tags = data.get('tags', [])
|
|
||||||
is_encrypted = 1 if data.get('is_encrypted') else 0
|
|
||||||
password = data.get('password', '').strip()
|
|
||||||
category = data.get('category')
|
|
||||||
|
|
||||||
# 본문 기반 메타데이터 통합 및 정리 ($그룹, #태그 하단 이동)
|
|
||||||
new_content, final_group, final_tags = parse_and_clean_metadata(content, ui_group=group_name, ui_tags=user_tags)
|
|
||||||
content = new_content
|
|
||||||
group_name = final_group
|
|
||||||
user_tags = final_tags
|
|
||||||
|
|
||||||
# 제목 자동 생성 (비어있을 경우)
|
|
||||||
if not title:
|
|
||||||
title = generate_auto_title(content)
|
|
||||||
|
|
||||||
if is_encrypted and password:
|
|
||||||
content = encrypt_content(content, password)
|
|
||||||
elif is_encrypted and not password:
|
|
||||||
return jsonify({'error': 'Password required for encryption'}), 400
|
|
||||||
|
|
||||||
now = datetime.datetime.now().isoformat()
|
|
||||||
if not title and not content:
|
|
||||||
return jsonify({'error': 'Title or content required'}), 400
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
c = conn.cursor()
|
|
||||||
try:
|
try:
|
||||||
|
data = request.json
|
||||||
|
title = data.get('title', '').strip()
|
||||||
|
content = data.get('content', '').strip()
|
||||||
|
color = data.get('color', '#2c3e50')
|
||||||
|
is_pinned = 1 if data.get('is_pinned') else 0
|
||||||
|
status = data.get('status', 'active').strip()
|
||||||
|
group_name = data.get('group_name', GROUP_DEFAULT).strip()
|
||||||
|
user_tags = data.get('tags', [])
|
||||||
|
is_encrypted = 1 if data.get('is_encrypted') else 0
|
||||||
|
password = data.get('password', '').strip()
|
||||||
|
category = data.get('category')
|
||||||
|
|
||||||
|
# 본문 기반 메타데이터 통합 및 정리 ($그룹, #태그 하단 이동)
|
||||||
|
new_content, final_group, final_tags = parse_and_clean_metadata(content, ui_group=group_name, ui_tags=user_tags)
|
||||||
|
content = new_content
|
||||||
|
group_name = final_group
|
||||||
|
user_tags = final_tags
|
||||||
|
|
||||||
|
# 제목 자동 생성 (비어있을 경우)
|
||||||
|
if not title:
|
||||||
|
title = generate_auto_title(content)
|
||||||
|
|
||||||
|
if is_encrypted and password:
|
||||||
|
content = encrypt_content(content, password)
|
||||||
|
elif is_encrypted and not password:
|
||||||
|
return jsonify({'error': 'Password required for encryption'}), 400
|
||||||
|
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
if not title and not content:
|
||||||
|
return jsonify({'error': 'Title or content required'}), 400
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
c.execute('''
|
c.execute('''
|
||||||
INSERT INTO memos (title, content, color, is_pinned, status, group_name, category, is_encrypted, created_at, updated_at)
|
INSERT INTO memos (title, content, color, is_pinned, status, group_name, category, is_encrypted, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -229,61 +229,63 @@ def create_memo():
|
|||||||
c.execute('UPDATE attachments SET memo_id = ? WHERE filename = ?', (memo_id, fname))
|
c.execute('UPDATE attachments SET memo_id = ? WHERE filename = ?', (memo_id, fname))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
current_app.logger.info(f"Memo Created: ID {memo_id}, Title: '{title}', Encrypted: {is_encrypted}")
|
current_app.logger.info(f"Memo Created: ID {memo_id}, Title: '{title}', Encrypted: {is_encrypted}")
|
||||||
return jsonify({'id': memo_id, 'message': 'Memo created'}), 201
|
return jsonify({'id': memo_id, 'message': 'Memo created'}), 201
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
if 'conn' in locals() and conn:
|
||||||
|
conn.rollback()
|
||||||
|
conn.close()
|
||||||
|
current_app.logger.error(f"CREATE_MEMO FAILED: {str(e)}")
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
@memo_bp.route('/api/memos/<int:memo_id>', methods=['PUT'])
|
@memo_bp.route('/api/memos/<int:memo_id>', methods=['PUT'])
|
||||||
@login_required
|
@login_required
|
||||||
def update_memo(memo_id):
|
def update_memo(memo_id):
|
||||||
data = request.json
|
|
||||||
title = data.get('title')
|
|
||||||
content = data.get('content')
|
|
||||||
color = data.get('color')
|
|
||||||
is_pinned = data.get('is_pinned')
|
|
||||||
status = data.get('status')
|
|
||||||
group_name = data.get('group_name')
|
|
||||||
user_tags = data.get('tags')
|
|
||||||
is_encrypted = data.get('is_encrypted')
|
|
||||||
password = data.get('password', '').strip()
|
|
||||||
category = data.get('category')
|
|
||||||
|
|
||||||
now = datetime.datetime.now().isoformat()
|
|
||||||
conn = get_db()
|
|
||||||
c = conn.cursor()
|
|
||||||
|
|
||||||
# 보안: 암호화된 메모 수정 시 비밀번호 검증
|
|
||||||
c.execute('SELECT content, is_encrypted, group_name FROM memos WHERE id = ?', (memo_id,))
|
|
||||||
memo = c.fetchone()
|
|
||||||
if memo and memo['is_encrypted']:
|
|
||||||
if not password:
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'error': _t('msg_encrypted_locked')}), 403
|
|
||||||
|
|
||||||
if decrypt_content(memo['content'], password) is None:
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'error': _t('msg_auth_failed')}), 403
|
|
||||||
|
|
||||||
# 본문 기반 메타데이터 통합 및 정리 ($그룹, #태그 하단 이동)
|
|
||||||
if content is not None:
|
|
||||||
new_content, final_group, final_tags = parse_and_clean_metadata(
|
|
||||||
content,
|
|
||||||
ui_group=(group_name or memo['group_name']),
|
|
||||||
ui_tags=(user_tags if user_tags is not None else [])
|
|
||||||
)
|
|
||||||
content = new_content
|
|
||||||
group_name = final_group
|
|
||||||
user_tags = final_tags
|
|
||||||
|
|
||||||
# 제목 자동 생성 (비어있을 경우)
|
|
||||||
if title == "":
|
|
||||||
title = generate_auto_title(content or "")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
data = request.json
|
||||||
|
title = data.get('title')
|
||||||
|
content = data.get('content')
|
||||||
|
color = data.get('color')
|
||||||
|
is_pinned = data.get('is_pinned')
|
||||||
|
status = data.get('status')
|
||||||
|
group_name = data.get('group_name')
|
||||||
|
user_tags = data.get('tags')
|
||||||
|
is_encrypted = data.get('is_encrypted')
|
||||||
|
password = data.get('password', '').strip()
|
||||||
|
category = data.get('category')
|
||||||
|
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
# 보안: 암호화된 메모 수정 시 비밀번호 검증
|
||||||
|
c.execute('SELECT content, is_encrypted, group_name FROM memos WHERE id = ?', (memo_id,))
|
||||||
|
memo = c.fetchone()
|
||||||
|
if memo and memo['is_encrypted']:
|
||||||
|
if not password:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': _t('msg_encrypted_locked')}), 403
|
||||||
|
|
||||||
|
if decrypt_content(memo['content'], password) is None:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': _t('msg_auth_failed')}), 403
|
||||||
|
|
||||||
|
# 본문 기반 메타데이터 통합 및 정리 ($그룹, #태그 하단 이동)
|
||||||
|
if content is not None:
|
||||||
|
new_content, final_group, final_tags = parse_and_clean_metadata(
|
||||||
|
content,
|
||||||
|
ui_group=(group_name or memo['group_name']),
|
||||||
|
ui_tags=(user_tags if user_tags is not None else [])
|
||||||
|
)
|
||||||
|
content = new_content
|
||||||
|
group_name = final_group
|
||||||
|
user_tags = final_tags
|
||||||
|
|
||||||
|
# 제목 자동 생성 (비어있을 경우)
|
||||||
|
if title == "":
|
||||||
|
title = generate_auto_title(content or "")
|
||||||
|
|
||||||
updates = ['updated_at = ?']
|
updates = ['updated_at = ?']
|
||||||
params = [now]
|
params = [now]
|
||||||
|
|
||||||
@@ -340,13 +342,15 @@ def update_memo(memo_id):
|
|||||||
c.execute("UPDATE memos SET is_encrypted = 0 WHERE id = ?", (memo_id,))
|
c.execute("UPDATE memos SET is_encrypted = 0 WHERE id = ?", (memo_id,))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
current_app.logger.info(f"Memo Updated: ID {memo_id}, Fields: {list(data.keys())}")
|
current_app.logger.info(f"Memo Updated: ID {memo_id}, Fields: {list(data.keys())}")
|
||||||
return jsonify({'message': 'Updated'})
|
return jsonify({'message': 'Updated'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
if 'conn' in locals() and conn:
|
||||||
|
conn.rollback()
|
||||||
|
conn.close()
|
||||||
|
current_app.logger.error(f"UPDATE_MEMO FAILED: {str(e)}")
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
@memo_bp.route('/api/memos/<int:memo_id>', methods=['DELETE'])
|
@memo_bp.route('/api/memos/<int:memo_id>', methods=['DELETE'])
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# [Bug Report] 세션 만료 시 메모 저장 에러 (Unexpected token '<')
|
||||||
|
|
||||||
|
## 버그 내용
|
||||||
|
- **현상**: 메모 작성 후 저장 버튼을 눌렀을 때 `Unexpected token '<', "<!DOCTYPE "... is not valid JSON` 에러 메시지 팝업 발생.
|
||||||
|
- **상세**: 장시간 창을 열어두어 세션이 만료된 상태에서 API 요청(POST/PUT)을 보낼 때, 서버가 401 에러 대신 로그인 페이지(HTML)로 리다이렉트 시킴. 프론트엔드는 JSON 응답을 기대했으나 HTML을 받게 되어 파싱 에러가 발생함.
|
||||||
|
|
||||||
|
## 조치 사항
|
||||||
|
1. **API 인증 방식 수정 (`app/auth.py`)**:
|
||||||
|
- `login_required` 데코레이터에서 `/api/` 경로 요청에 대해서는 리다이렉트 대신 `401 Unauthorized` JSON 응답을 반환하도록 수정.
|
||||||
|
2. **에러 핸들링 강화 (`app/routes/memo.py`)**:
|
||||||
|
- `create_memo`, `update_memo` 함수 전체를 `try-except`로 감싸 데이터 처리 중 발생하는 예외가 항상 JSON 형식으로 반환되도록 보장.
|
||||||
|
3. **전역 에러 핸들러 개선 (`app/__init__.py`)**:
|
||||||
|
- `403 Forbidden` 및 기타 예외 발생 시 API 요청인 경우 JSON 응답을 반환하도록 처리.
|
||||||
|
|
||||||
|
## 결과
|
||||||
|
- 세션이 만료된 상태에서 저장 시, 프론트엔드가 401 상태 코드를 정확히 인지하여 로그인 페이지로 자연스럽게 유도됨.
|
||||||
|
- 예상치 못한 서버 에러 발생 시에도 사용자에게 깨진 코드가 아닌 명확한 에러 메시지 제공 가능.
|
||||||
|
|
||||||
|
## 향후 주의 사항
|
||||||
|
- 새로운 API 라우트 추가 시 반드시 `jasonify`를 통한 JSON 응답을 보장해야 함.
|
||||||
|
- 프론트엔드(`api.js`)의 `res.ok` 체크 로직이 302 리다이렉트 등을 만나면 HTML을 받을 수 있음을 항상 인지하고 백엔드에서 적절한 상태 코드(4xx)를 반환할 것.
|
||||||
Reference in New Issue
Block a user