mirror of
https://github.com/sotam0316/brain_dogfood.git
synced 2026-04-25 03:48:38 +09:00
Initial Global Release v1.0 (Localization & Security Hardening)
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user