|
|
from flask import Flask, request, jsonify, send_from_directory, abort |
|
|
from flask_cors import CORS |
|
|
import os |
|
|
import glob |
|
|
import re |
|
|
import base64 |
|
|
import time |
|
|
import tempfile |
|
|
import logging |
|
|
|
|
|
|
|
|
logging.basicConfig( |
|
|
level=logging.INFO, |
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' |
|
|
) |
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
app = Flask(__name__) |
|
|
|
|
|
|
|
|
|
|
|
CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*') |
|
|
CORS(app, origins=CORS_ORIGINS.split(',') if CORS_ORIGINS != '*' else '*') |
|
|
|
|
|
|
|
|
DATA_DIR = os.environ.get('DATA_DIR', '/tmp') |
|
|
MAX_TOTAL_SIZE_MB = int(os.environ.get('MAX_TOTAL_SIZE_MB', 100)) |
|
|
PURGE_TO_SIZE_MB = int(os.environ.get('PURGE_TO_SIZE_MB', 80)) |
|
|
AGE_LIMIT_DAYS = int(os.environ.get('AGE_LIMIT_DAYS', 2)) |
|
|
MAX_CONTENT_SIZE_MB = int(os.environ.get('MAX_CONTENT_SIZE_MB', 10)) |
|
|
|
|
|
|
|
|
STATIC_DIR = os.environ.get('STATIC_DIR', os.path.dirname(os.path.abspath(__file__))) |
|
|
|
|
|
|
|
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 |
|
|
|
|
|
|
|
|
def sanitize_hash(hash_string): |
|
|
""" |
|
|
Validates that the hash is a 16-character hexadecimal string. |
|
|
This is CRITICAL to prevent path traversal attacks. |
|
|
""" |
|
|
if not isinstance(hash_string, str): |
|
|
return False |
|
|
|
|
|
return bool(re.match(r'^[0-9a-f]{16}$', hash_string)) |
|
|
|
|
|
|
|
|
def cleanup_files(): |
|
|
""" |
|
|
Remove old files based on two conditions: |
|
|
1. Any file older than AGE_LIMIT_DAYS is removed. |
|
|
2. If total size still exceeds MAX_TOTAL_SIZE_MB, the oldest remaining files are removed |
|
|
until the total size is below PURGE_TO_SIZE_MB. |
|
|
""" |
|
|
try: |
|
|
content_files = glob.glob(os.path.join(DATA_DIR, '*_content.txt')) |
|
|
if not content_files: |
|
|
return |
|
|
|
|
|
now = time.time() |
|
|
age_limit_seconds = AGE_LIMIT_DAYS * 24 * 60 * 60 |
|
|
age_threshold = now - age_limit_seconds |
|
|
|
|
|
all_file_info = [] |
|
|
for f_path in content_files: |
|
|
try: |
|
|
mtime = os.path.getmtime(f_path) |
|
|
size = os.path.getsize(f_path) |
|
|
all_file_info.append({'path': f_path, 'size': size, 'mtime': mtime}) |
|
|
except OSError: |
|
|
continue |
|
|
|
|
|
|
|
|
files_to_keep = [] |
|
|
files_to_delete = [] |
|
|
|
|
|
for f_info in all_file_info: |
|
|
if f_info['mtime'] < age_threshold: |
|
|
files_to_delete.append(f_info) |
|
|
else: |
|
|
files_to_keep.append(f_info) |
|
|
|
|
|
|
|
|
current_size_of_kept_files = sum(f['size'] for f in files_to_keep) |
|
|
max_size_bytes = MAX_TOTAL_SIZE_MB * 1024 * 1024 |
|
|
|
|
|
if current_size_of_kept_files > max_size_bytes: |
|
|
|
|
|
files_to_keep.sort(key=lambda x: x['mtime']) |
|
|
|
|
|
target_size_bytes = PURGE_TO_SIZE_MB * 1024 * 1024 |
|
|
|
|
|
|
|
|
while current_size_of_kept_files > target_size_bytes and files_to_keep: |
|
|
file_to_move = files_to_keep.pop(0) |
|
|
files_to_delete.append(file_to_move) |
|
|
current_size_of_kept_files -= file_to_move['size'] |
|
|
|
|
|
|
|
|
if not files_to_delete: |
|
|
return |
|
|
|
|
|
logger.info(f"Cleanup: Deleting {len(files_to_delete)} old/oversized file(s).") |
|
|
for f_info in files_to_delete: |
|
|
try: |
|
|
content_path = f_info['path'] |
|
|
salt_path = content_path.replace('_content.txt', '_salt.txt') |
|
|
|
|
|
os.remove(content_path) |
|
|
if os.path.exists(salt_path): |
|
|
os.remove(salt_path) |
|
|
except OSError as e: |
|
|
logger.error(f"Cleanup: Error removing file {f_info['path']}: {e}") |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Error during file cleanup: {e}") |
|
|
|
|
|
|
|
|
@app.route('/') |
|
|
def index(): |
|
|
return send_from_directory(STATIC_DIR, 'index.html') |
|
|
|
|
|
@app.route('/health') |
|
|
def health(): |
|
|
"""Health check endpoint for monitoring.""" |
|
|
return jsonify({'status': 'ok'}) |
|
|
|
|
|
@app.route('/api/load', methods=['POST']) |
|
|
def load_content(): |
|
|
data = request.json |
|
|
if not data: |
|
|
return jsonify({'error': 'Invalid JSON payload'}), 400 |
|
|
file_hash = data.get('hash', '') |
|
|
|
|
|
|
|
|
if not sanitize_hash(file_hash): |
|
|
return jsonify({'error': 'Invalid hash format'}), 400 |
|
|
|
|
|
content_path = os.path.join(DATA_DIR, f'{file_hash}_content.txt') |
|
|
salt_path = os.path.join(DATA_DIR, f'{file_hash}_salt.txt') |
|
|
|
|
|
try: |
|
|
|
|
|
if os.path.exists(salt_path): |
|
|
with open(salt_path, 'r', encoding='utf-8') as f: |
|
|
salt_b64 = f.read() |
|
|
else: |
|
|
|
|
|
salt_bytes = os.urandom(16) |
|
|
salt_b64 = base64.b64encode(salt_bytes).decode('utf-8') |
|
|
|
|
|
try: |
|
|
fd, tmp_path = tempfile.mkstemp(dir=DATA_DIR, suffix='.tmp') |
|
|
os.write(fd, salt_b64.encode('utf-8')) |
|
|
os.close(fd) |
|
|
os.rename(tmp_path, salt_path) |
|
|
except OSError: |
|
|
|
|
|
with open(salt_path, 'w', encoding='utf-8') as f: |
|
|
f.write(salt_b64) |
|
|
|
|
|
|
|
|
if os.path.exists(content_path): |
|
|
with open(content_path, 'r', encoding='utf-8') as f: |
|
|
encrypted_content = f.read() |
|
|
else: |
|
|
encrypted_content = '' |
|
|
|
|
|
return jsonify({'content': encrypted_content, 'salt': salt_b64}) |
|
|
except Exception as e: |
|
|
logger.error(f"Error during load: {e}") |
|
|
return jsonify({'error': 'Failed to load content from server'}), 500 |
|
|
|
|
|
@app.route('/api/save', methods=['POST']) |
|
|
def save_content(): |
|
|
data = request.json |
|
|
if not data: |
|
|
return jsonify({'error': 'Invalid JSON payload'}), 400 |
|
|
file_hash = data.get('hash', '') |
|
|
encrypted_content = data.get('content', '') |
|
|
|
|
|
|
|
|
if not sanitize_hash(file_hash): |
|
|
return jsonify({'error': 'Invalid hash format'}), 400 |
|
|
|
|
|
|
|
|
if not isinstance(encrypted_content, str): |
|
|
return jsonify({'error': 'Invalid content format'}), 400 |
|
|
|
|
|
|
|
|
max_content_bytes = MAX_CONTENT_SIZE_MB * 1024 * 1024 |
|
|
if len(encrypted_content.encode('utf-8')) > max_content_bytes: |
|
|
return jsonify({'error': f'Content too large. Maximum size is {MAX_CONTENT_SIZE_MB}MB'}), 413 |
|
|
|
|
|
content_path = os.path.join(DATA_DIR, f'{file_hash}_content.txt') |
|
|
|
|
|
try: |
|
|
|
|
|
with open(content_path, 'w', encoding='utf-8') as f: |
|
|
f.write(encrypted_content) |
|
|
|
|
|
|
|
|
cleanup_files() |
|
|
|
|
|
return jsonify({'status': 'saved'}) |
|
|
except Exception as e: |
|
|
logger.error(f"Error during save: {e}") |
|
|
return jsonify({'error': 'Save failed on server'}), 500 |
|
|
|
|
|
if __name__ == '__main__': |
|
|
app.run(host='0.0.0.0', port=7860, debug=False) |