Initial Global Release v1.0 (Localization & Security Hardening)

This commit is contained in:
leeyj
2026-04-16 01:12:43 +09:00
commit 175a30325b
67 changed files with 6348 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
from flask import Blueprint # type: ignore
def register_blueprints(app):
from .main import main_bp
from .auth import auth_bp
from .memo import memo_bp
from .file import file_bp
from .ai import ai_bp
from .settings import settings_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(memo_bp)
app.register_blueprint(file_bp)
app.register_blueprint(ai_bp)
app.register_blueprint(settings_bp)
+46
View File
@@ -0,0 +1,46 @@
import datetime
from flask import Blueprint, jsonify, current_app # type: ignore
from ..database import get_db
from ..auth import login_required
from ..ai import analyze_memo
from ..utils.i18n import _t
ai_bp = Blueprint('ai', __name__)
@ai_bp.route('/api/memos/<int:memo_id>/analyze', methods=['POST'])
@login_required
def analyze_memo_route(memo_id):
conn = get_db()
c = conn.cursor()
c.execute('SELECT title, content, is_encrypted FROM memos WHERE id = ?', (memo_id,))
memo = c.fetchone()
if not memo:
return jsonify({'error': _t('label_no_results')}), 404
if memo['is_encrypted']:
return jsonify({'error': _t('msg_encrypted_locked')}), 403
current_app.logger.info(f"AI Analysis Started: ID {memo_id}, Title: '{memo['title']}'")
lang = current_app.config.get('lang', 'en')
summary, ai_tags = analyze_memo(memo['title'], memo['content'], lang=lang)
try:
c.execute('UPDATE memos SET summary = ?, updated_at = ? WHERE id = ?',
(summary, datetime.datetime.now().isoformat(), memo_id))
c.execute("DELETE FROM tags WHERE memo_id = ? AND source = 'ai'", (memo_id,))
for tag in ai_tags:
if tag.strip():
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)',
(memo_id, tag.strip(), 'ai'))
conn.commit()
current_app.logger.info(f"AI Analysis SUCCESS: ID {memo_id}, Tags extracted: {len(ai_tags)}")
return jsonify({'summary': summary, 'tags': ai_tags})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
+22
View File
@@ -0,0 +1,22 @@
from flask import Blueprint, request, jsonify, session, redirect, url_for # type: ignore
from ..auth import check_auth
from ..utils.i18n import _t
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['POST'])
def login():
data = request.json
username = data.get('username')
password = data.get('password')
if check_auth(username, password):
session.permanent = True # Enable permanent session to use LIFETIME config
session['logged_in'] = True
return jsonify({'message': 'Logged in successfully'})
return jsonify({'error': _t('msg_auth_failed')}), 401
@auth_bp.route('/logout')
def logout():
session.pop('logged_in', None)
return redirect(url_for('main.login_page'))
+162
View File
@@ -0,0 +1,162 @@
import os
import uuid
import datetime
import mimetypes
from flask import Blueprint, request, jsonify, current_app, Response, send_from_directory, session # type: ignore
from werkzeug.utils import secure_filename # type: ignore
from urllib.parse import quote # type: ignore
from ..database import get_db
from ..auth import login_required
from ..security import encrypt_file, decrypt_file
file_bp = Blueprint('file', __name__)
@file_bp.route('/api/upload', methods=['POST'])
@login_required
def upload_file():
if 'image' not in request.files and 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files.get('image') or request.files.get('file')
if not file or file.filename == '':
return jsonify({'error': 'No selected file'}), 400
ext = os.path.splitext(file.filename)[1].lower().replace('.', '')
sec_conf = current_app.config.get('UPLOAD_SECURITY', {})
blocked = sec_conf.get('blocked_extensions', [])
allowed = sec_conf.get('allowed_extensions', [])
if ext in blocked:
return jsonify({'error': f'Extension .{ext} is blocked for security reasons.'}), 403
if allowed and ext not in allowed:
return jsonify({'error': f'Extension .{ext} is not in the allowed list.'}), 403
unique_filename = f"{uuid.uuid4()}.{ext}"
filename = secure_filename(unique_filename)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
# Encrypt and save
file_bytes = file.read()
encrypted_bytes = encrypt_file(file_bytes)
with open(filepath, 'wb') as f:
f.write(encrypted_bytes)
# Record attachment in DB
conn = get_db()
c = conn.cursor()
c.execute('''
INSERT INTO attachments (filename, original_name, file_type, size, created_at)
VALUES (?, ?, ?, ?, ?)
''', (filename, file.filename, ext, os.path.getsize(filepath), datetime.datetime.now().isoformat()))
conn.commit()
conn.close()
return jsonify({
'url': f"/api/download/{filename}",
'name': file.filename,
'ext': ext
})
@file_bp.route('/api/download/<filename>')
def download_file_route(filename):
filename = secure_filename(filename)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if not os.path.exists(filepath):
return jsonify({'error': 'File not found'}), 404
# Check security status of parent memo
conn = get_db()
c = conn.cursor()
c.execute('''
SELECT a.original_name, m.is_encrypted
FROM attachments a
LEFT JOIN memos m ON a.memo_id = m.id
WHERE a.filename = ?
''', (filename,))
row = c.fetchone()
conn.close()
# 로그인된 상태라면 암호화된 메모의 파일이라도 본인 확인이 된 것으로 간주하고 허용
is_logged_in = session.get('logged_in') is True
# 만약 메모가 암호화되어 있고, 로그인도 되어 있지 않다면 차단
if row and row['is_encrypted'] and not is_logged_in:
current_app.logger.warning(f"Access Denied: Unauthenticated access to encrypted file {filename}")
return jsonify({'error': 'Access denied. Please login to view this attachment.'}), 403
with open(filepath, 'rb') as f:
data = f.read()
decrypted = decrypt_file(data)
orig_name = row['original_name'] if row else filename
# 원본 파일명 기반으로 정확한 마임타입 추측
mime_type, _ = mimetypes.guess_type(orig_name)
if not mime_type: mime_type = 'application/octet-stream'
# 이미지인 경우 'inline'으로 설정하여 브라우저 본문 내 렌더링 허용, 그 외는 'attachment'
is_image = mime_type.startswith('image/')
disposition = 'inline' if is_image else 'attachment'
headers = {
'Content-Disposition': f"{disposition}; filename*=UTF-8''{quote(orig_name)}"
}
content_data = decrypted if decrypted is not None else data
return Response(content_data, mimetype=mime_type, headers=headers)
@file_bp.route('/api/assets', methods=['GET'])
@login_required
def get_assets():
conn = get_db()
c = conn.cursor()
# Filter out files belonging to encrypted memos
c.execute('''
SELECT a.*, m.title as memo_title
FROM attachments a
LEFT JOIN memos m ON a.memo_id = m.id
WHERE m.is_encrypted = 0 OR m.is_encrypted IS NULL
ORDER BY a.created_at DESC
''')
assets = [dict(r) for r in c.fetchall()]
conn.close()
return jsonify(assets)
@file_bp.route('/api/attachments/<filename>', methods=['DELETE'])
@login_required
def delete_attachment_route(filename):
filename = secure_filename(filename)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
conn = get_db()
c = conn.cursor()
# 파일 정보 확인
c.execute('SELECT id, memo_id FROM attachments WHERE filename = ?', (filename,))
row = c.fetchone()
if not row:
conn.close()
return jsonify({'error': 'File not found in database'}), 404
# 보안: 메모에 이미 연결된 파일은 삭제하지 않음 (취소 시에는 아직 연결되지 않은 파일만 삭제)
# 만약 연결된 파일을 삭제하고 싶다면 별도의 로직 필요
if row['memo_id'] is not None:
conn.close()
return jsonify({'error': 'Cannot delete file already linked to a memo'}), 403
try:
# 1. DB 삭제
c.execute('DELETE FROM attachments WHERE filename = ?', (filename,))
conn.commit()
# 2. 물리 파일 삭제
if os.path.exists(filepath):
os.remove(filepath)
current_app.logger.info(f"Attachment Deleted: {filename}")
return jsonify({'message': 'File deleted successfully'})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
+27
View File
@@ -0,0 +1,27 @@
from flask import Blueprint, render_template, redirect, url_for, session, current_app # type: ignore
from ..auth import login_required
import os
import json
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
@login_required
def index():
return render_template('index.html')
@main_bp.route('/login', methods=['GET'])
def login_page():
if 'logged_in' in session:
return redirect(url_for('main.index'))
# i18n 지원을 위해 기본 언어 전달
config_path = os.path.join(os.getcwd(), 'config.json')
lang = 'ko'
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
lang = json.load(f).get('lang', 'ko')
except: pass
return render_template('login.html', lang=lang)
+356
View File
@@ -0,0 +1,356 @@
import datetime
from flask import Blueprint, request, jsonify, current_app # type: ignore
from ..database import get_db
from ..auth import login_required
from ..constants import GROUP_DONE, GROUP_DEFAULT
from ..utils.i18n import _t
from ..utils import extract_links
from ..security import encrypt_content, decrypt_content
memo_bp = Blueprint('memo', __name__)
@memo_bp.route('/api/memos', methods=['GET'])
@login_required
def get_memos():
limit = request.args.get('limit', 20, type=int)
offset = request.args.get('offset', 0, type=int)
group = request.args.get('group', 'all')
query = request.args.get('query', '')
date = request.args.get('date', '')
conn = get_db()
c = conn.cursor()
where_clauses = []
params = []
# 1. 그룹/태그 필터링
if group == GROUP_DONE:
where_clauses.append("status = 'done'")
elif group.startswith('tag:'):
tag_name = group.split(':')[-1]
where_clauses.append("status != 'done'")
where_clauses.append("id IN (SELECT memo_id FROM tags WHERE name = ?)")
params.append(tag_name)
elif group != 'all':
where_clauses.append("status != 'done'")
where_clauses.append("group_name = ?")
params.append(group)
else:
where_clauses.append("status != 'done'")
# 2. 검색어 필터링
if query:
where_clauses.append("(title LIKE ? OR content LIKE ?)")
params.append(f"%{query}%")
params.append(f"%{query}%")
# 3. 날짜 필터링 (캘린더 선택)
if date:
where_clauses.append("created_at LIKE ?")
params.append(f"{date}%")
# 4. 초기 로드 시 최근 5일 강조 (필터가 없는 경우에만 적용)
if offset == 0 and group == 'all' and not query and not date:
start_date = (datetime.datetime.now() - datetime.timedelta(days=5)).isoformat()
where_clauses.append("(updated_at >= ? OR is_pinned = 1)")
params.append(start_date)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
query_sql = f"SELECT * FROM memos WHERE {where_sql} ORDER BY is_pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
c.execute(query_sql, params + [limit, offset])
memo_rows = c.fetchall()
if not memo_rows:
conn.close()
return jsonify([])
memos = [dict(r) for r in memo_rows]
memo_ids = [m['id'] for m in memos]
placeholders = ','.join(['?'] * len(memo_ids))
# --- 🚀 Bulk Fetch: N+1 문제 해결 ---
# 태그 한꺼번에 가져오기
c.execute(f'SELECT memo_id, name, source FROM tags WHERE memo_id IN ({placeholders})', memo_ids)
tags_rows = c.fetchall()
tags_map = {}
for t in tags_rows:
tags_map.setdefault(t['memo_id'], []).append(dict(t))
# 첨부파일 한꺼번에 가져오기
c.execute(f'SELECT id, memo_id, filename, original_name, file_type, size FROM attachments WHERE memo_id IN ({placeholders})', memo_ids)
attachments_rows = c.fetchall()
attachments_map = {}
for a in attachments_rows:
attachments_map.setdefault(a['memo_id'], []).append(dict(a))
# 백링크 한꺼번에 가져오기
c.execute(f'''
SELECT ml.target_id, m.id as source_id, m.title
FROM memo_links ml
JOIN memos m ON ml.source_id = m.id
WHERE ml.target_id IN ({placeholders})
''', memo_ids)
backlinks_rows = c.fetchall()
backlinks_map = {}
for l in backlinks_rows:
backlinks_map.setdefault(l['target_id'], []).append(dict(l))
# 전방 링크(Forward Links) 한꺼번에 가져오기
c.execute(f'''
SELECT ml.source_id, m.id as target_id, m.title
FROM memo_links ml
JOIN memos m ON ml.target_id = m.id
WHERE ml.source_id IN ({placeholders})
''', memo_ids)
links_rows = c.fetchall()
links_map = {}
for l in links_rows:
links_map.setdefault(l['source_id'], []).append(dict(l))
# 데이터 가공 및 병합
for m in memos:
m['tags'] = tags_map.get(m['id'], [])
m['attachments'] = attachments_map.get(m['id'], [])
m['backlinks'] = backlinks_map.get(m['id'], [])
m['links'] = links_map.get(m['id'], [])
conn.close()
return jsonify(memos)
@memo_bp.route('/api/stats/heatmap', methods=['GET'])
@login_required
def get_heatmap_stats():
days = request.args.get('days', 365, type=int)
conn = get_db()
c = conn.cursor()
# 파라미터로 받은 일수만큼 데이터 조회
start_date = (datetime.datetime.now() - datetime.timedelta(days=days)).isoformat()
c.execute('''
SELECT strftime('%Y-%m-%d', created_at) as date, COUNT(*) as count
FROM memos
WHERE created_at >= ?
GROUP BY date
''', (start_date,))
stats = c.fetchall()
conn.close()
return jsonify([dict(s) for s in stats])
@memo_bp.route('/api/memos', methods=['POST'])
@login_required
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()
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:
c.execute('''
INSERT INTO memos (title, content, color, is_pinned, status, group_name, is_encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (title, content, color, is_pinned, status, group_name, is_encrypted, now, now))
memo_id = c.lastrowid
for tag in user_tags:
if tag.strip():
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)', (memo_id, tag.strip(), 'user'))
links = extract_links(content)
for target_id in links:
c.execute('INSERT INTO memo_links (source_id, target_id) VALUES (?, ?)', (memo_id, target_id))
attachment_filenames = data.get('attachment_filenames', [])
for fname in set(attachment_filenames):
c.execute('UPDATE attachments SET memo_id = ? WHERE filename = ?', (memo_id, fname))
conn.commit()
current_app.logger.info(f"Memo Created: ID {memo_id}, Title: '{title}', Encrypted: {is_encrypted}")
return jsonify({'id': memo_id, 'message': 'Memo created'}), 201
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@memo_bp.route('/api/memos/<int:memo_id>', methods=['PUT'])
@login_required
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()
now = datetime.datetime.now().isoformat()
conn = get_db()
c = conn.cursor()
# 보안: 암호화된 메모 수정 시 비밀번호 검증
c.execute('SELECT content, is_encrypted FROM memos WHERE id = ?', (memo_id,))
memo = c.fetchone()
if memo and memo['is_encrypted']:
# 암호화된 메모지만 '암호화 해제(is_encrypted=0)' 요청이 온 경우는
# 비밀번호 없이도 수정을 시도할 수 있어야 할까? (아니오, 인증이 필요함)
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
try:
updates = ['updated_at = ?']
params = [now]
# 암호화 처리 로직: 암호화가 활성화된 경우(또는 새로 설정하는 경우) 본문 암호화
final_content = content.strip() if content is not None else None
if (is_encrypted or (is_encrypted is None and memo['is_encrypted'])) and password:
if final_content is not None:
final_content = encrypt_content(final_content, password)
if title is not None:
updates.append('title = ?'); params.append(title.strip())
if final_content is not None:
updates.append('content = ?'); params.append(final_content)
if color is not None:
updates.append('color = ?'); params.append(color)
if is_pinned is not None:
updates.append('is_pinned = ?'); params.append(1 if is_pinned else 0)
if status is not None:
updates.append('status = ?'); params.append(status.strip())
if group_name is not None:
updates.append('group_name = ?'); params.append(group_name.strip() or GROUP_DEFAULT)
if is_encrypted is not None:
updates.append('is_encrypted = ?'); params.append(1 if is_encrypted else 0)
params.append(memo_id)
c.execute(f"UPDATE memos SET {', '.join(updates)} WHERE id = ?", params)
if user_tags is not None:
c.execute("DELETE FROM tags WHERE memo_id = ? AND source = 'user'", (memo_id,))
for tag in user_tags:
if tag.strip():
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)', (memo_id, tag.strip(), 'user'))
if content is not None:
c.execute("DELETE FROM memo_links WHERE source_id = ?", (memo_id,))
links = extract_links(content)
for target_id in links:
c.execute('INSERT INTO memo_links (source_id, target_id) VALUES (?, ?)', (memo_id, target_id))
# [Bug Fix] 첨부파일 링크는 본문 수정 여부와 상관없이 항상 갱신
attachment_filenames = data.get('attachment_filenames')
if attachment_filenames is not None:
c.execute('UPDATE attachments SET memo_id = NULL WHERE memo_id = ?', (memo_id,))
for fname in set(attachment_filenames):
c.execute('UPDATE attachments SET memo_id = ? WHERE filename = ?', (memo_id, fname))
if is_encrypted is not None:
if is_encrypted and password and content:
enc_content = encrypt_content(content, password)
c.execute("UPDATE memos SET is_encrypted = 1, content = ? WHERE id = ?", (enc_content, memo_id))
elif is_encrypted == 0:
c.execute("UPDATE memos SET is_encrypted = 0 WHERE id = ?", (memo_id,))
conn.commit()
current_app.logger.info(f"Memo Updated: ID {memo_id}, Fields: {list(data.keys())}")
return jsonify({'message': 'Updated'})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@memo_bp.route('/api/memos/<int:memo_id>', methods=['DELETE'])
@login_required
def delete_memo(memo_id):
import os
from flask import current_app
conn = get_db()
c = conn.cursor()
# 1. 암호화 여부 확인 및 파일 목록 가져오기
c.execute('SELECT is_encrypted FROM memos WHERE id = ?', (memo_id,))
memo = c.fetchone()
if not memo:
conn.close()
return jsonify({'error': 'Memo not found'}), 404
if memo['is_encrypted']:
conn.close()
return jsonify({'error': _t('msg_encrypted_locked')}), 403
# 2. 물리적 파일 삭제 준비
c.execute('SELECT filename FROM attachments WHERE memo_id = ?', (memo_id,))
files = c.fetchall()
upload_folder = current_app.config['UPLOAD_FOLDER']
try:
# 3. 물리 파일 삭제 루프
for f in files:
filepath = os.path.join(upload_folder, f['filename'])
if os.path.exists(filepath):
os.remove(filepath)
current_app.logger.info(f"Physical file deleted on memo removal: {f['filename']}")
# 4. 메모 삭제 (외래 키 제약 조건에 의해 tags 등은 자동 삭제되거나 처리됨)
# Note: attachments 테이블의 memo_id는 SET NULL 설정이므로 수동으로 레코드도 삭제해줍니다.
c.execute('DELETE FROM attachments WHERE memo_id = ?', (memo_id,))
c.execute('DELETE FROM memos WHERE id = ?', (memo_id,))
conn.commit()
current_app.logger.info(f"Memo and its {len(files)} files deleted: ID {memo_id}")
return jsonify({'message': 'Deleted memo and all associated files'})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@memo_bp.route('/api/memos/<int:memo_id>/decrypt', methods=['POST'])
@login_required
def decrypt_memo_route(memo_id):
data = request.json
password = data.get('password')
if not password: return jsonify({'error': 'Password required'}), 400
conn = get_db(); c = conn.cursor()
c.execute('SELECT content, is_encrypted FROM memos WHERE id = ?', (memo_id,))
memo = c.fetchone(); conn.close()
if not memo: return jsonify({'error': 'Memo not found'}), 404
if not memo['is_encrypted']: return jsonify({'content': memo['content']})
decrypted = decrypt_content(memo['content'], password)
if decrypted is None:
current_app.logger.warning(f"Decryption FAILED: ID {memo_id}")
return jsonify({'error': 'Invalid password'}), 403
current_app.logger.info(f"Decryption SUCCESS: ID {memo_id}")
return jsonify({'content': decrypted})
+58
View File
@@ -0,0 +1,58 @@
import os
import json
from flask import Blueprint, request, jsonify, current_app # type: ignore
from ..auth import login_required
settings_bp = Blueprint('settings', __name__)
CONFIG_PATH = os.path.join(os.getcwd(), 'config.json')
# 기본 테마 및 시스템 설정
DEFAULT_SETTINGS = {
"bg_color": "#0f172a",
"sidebar_color": "rgba(30, 41, 59, 0.7)",
"card_color": "rgba(30, 41, 59, 0.85)",
"encrypted_border": "#00f3ff",
"ai_accent": "#8b5cf6",
"enable_ai": True,
"lang": "ko"
}
@settings_bp.route('/api/settings', methods=['GET'])
@login_required
def get_settings():
if not os.path.exists(CONFIG_PATH):
return jsonify(DEFAULT_SETTINGS)
try:
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
# 기본값과 병합하여 신규 필드 등 누락 방지
full_data = {**DEFAULT_SETTINGS, **data}
return jsonify(full_data)
except Exception as e:
return jsonify(DEFAULT_SETTINGS)
@settings_bp.route('/api/settings', methods=['POST'])
@login_required
def save_settings():
data = request.json
if not data:
return jsonify({'error': 'No data provided'}), 400
try:
# 기존 데이터 로드 후 병합
current_data = {}
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
current_data = json.load(f)
updated_data = {**current_data, **data}
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(updated_data, f, indent=4, ensure_ascii=False)
current_app.logger.info(f"System Settings Updated: {list(data.keys())}")
return jsonify({'message': 'Settings saved successfully'})
except Exception as e:
return jsonify({'error': str(e)}), 500