Files
brain_dogfood/app/routes/file.py
T

163 lines
5.9 KiB
Python

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()