# media_storage.py - File-based media storage manager (v4.3) import os import base64 import shutil from config import MEDIA_STORAGE_DIR def _ensure_dir(path): os.makedirs(path, exist_ok=True) def get_project_dir(project_id): """প্রজেক্টের base ডিরেক্টরি (absolute path) রিটার্ন করে।""" return os.path.join(MEDIA_STORAGE_DIR, f'project_{int(project_id)}') def get_audio_dir(project_id): return os.path.join(get_project_dir(project_id), 'audio') def get_images_dir(project_id): return os.path.join(get_project_dir(project_id), 'images') # ============================================ # Save operations (base64 string → file) # ============================================ def save_audio(project_id, block_id, audio_base64, audio_format='mp3'): """ Audio ফাইল সেভ করে relative path রিটার্ন করে। relative path ডেটাবেসে জমা হবে, যেমন: project_5/audio/block_123.mp3 """ if not audio_base64: return None audio_dir = get_audio_dir(project_id) _ensure_dir(audio_dir) fmt = (audio_format or 'mp3').lower() filename = f'block_{int(block_id)}.{fmt}' abs_path = os.path.join(audio_dir, filename) with open(abs_path, 'wb') as f: f.write(base64.b64decode(audio_base64)) return f'project_{int(project_id)}/audio/{filename}' def save_image(project_id, image_id, image_base64, image_format='png'): """Image ফাইল সেভ করে relative path রিটার্ন করে।""" if not image_base64: return None images_dir = get_images_dir(project_id) _ensure_dir(images_dir) fmt = (image_format or 'png').lower() filename = f'img_{int(image_id)}.{fmt}' abs_path = os.path.join(images_dir, filename) with open(abs_path, 'wb') as f: f.write(base64.b64decode(image_base64)) return f'project_{int(project_id)}/images/{filename}' def save_thumbnail(project_id, image_bytes, image_format='png'): """Thumbnail সেভ করে relative path রিটার্ন করে (raw bytes নেয়)।""" if not image_bytes: return None proj_dir = get_project_dir(project_id) _ensure_dir(proj_dir) fmt = (image_format or 'png').lower() filename = f'thumbnail.{fmt}' abs_path = os.path.join(proj_dir, filename) # পুরোনো thumbnail অন্য ফরম্যাটে থাকলে মুছে দিই for old_ext in ('png', 'jpeg', 'jpg', 'webp', 'gif'): old = os.path.join(proj_dir, f'thumbnail.{old_ext}') if old_ext != fmt and os.path.exists(old): try: os.remove(old) except OSError: pass with open(abs_path, 'wb') as f: f.write(image_bytes) return f'project_{int(project_id)}/{filename}' # ============================================ # Read operations (file → bytes / base64) # ============================================ def _safe_abs_path(relative_path): """ relative path কে absolute path-এ রূপান্তর করে এবং নিশ্চিত করে যে এটা MEDIA_STORAGE_DIR-এর বাইরে যাচ্ছে না (path traversal রোধ)। """ if not relative_path: return None base = os.path.realpath(MEDIA_STORAGE_DIR) abs_path = os.path.realpath(os.path.join(base, relative_path)) if not abs_path.startswith(base + os.sep) and abs_path != base: return None # নিরাপত্তা লঙ্ঘন return abs_path def read_file_bytes(relative_path): """ফাইলের raw bytes রিটার্ন করে। না থাকলে None।""" abs_path = _safe_abs_path(relative_path) if not abs_path or not os.path.exists(abs_path): return None with open(abs_path, 'rb') as f: return f.read() def read_file_base64(relative_path): """ফাইল base64 স্ট্রিং হিসেবে রিটার্ন করে। না থাকলে খালি স্ট্রিং।""" data = read_file_bytes(relative_path) if data is None: return '' return base64.b64encode(data).decode('utf-8') def get_safe_abs_path(relative_path): """Flask send_file-এর জন্য নিরাপদ absolute path।""" return _safe_abs_path(relative_path) # ============================================ # Storage stats (v4.3) # ============================================ def get_storage_usage_bytes(): """media-storage ফোল্ডারের মোট সাইজ (bytes) রিটার্ন করে।""" total = 0 if not os.path.exists(MEDIA_STORAGE_DIR): return 0 for root, _, files in os.walk(MEDIA_STORAGE_DIR): for name in files: fp = os.path.join(root, name) try: total += os.path.getsize(fp) except OSError: pass return total # ============================================ # Pending thumbnails (v4.4) — আপলোডের সময় জেনারেট, সেভের সময় commit # ============================================ import uuid as _uuid def _pending_thumbs_dir(): return os.path.join(MEDIA_STORAGE_DIR, '_pending_thumbs') def save_pending_thumbnail(image_bytes, image_format='jpeg'): """ অস্থায়ী থাম্বনেইল সেভ করে একটা token রিটার্ন করে। প্রজেক্ট এখনো তৈরি হয়নি বলে project_id ছাড়াই রাখা হয়। """ if not image_bytes: return None d = _pending_thumbs_dir() _ensure_dir(d) fmt = (image_format or 'jpeg').lower() token = _uuid.uuid4().hex filename = f'{token}.{fmt}' with open(os.path.join(d, filename), 'wb') as f: f.write(image_bytes) return filename # token = filename def read_pending_thumbnail(token): """Pending thumbnail-এর (bytes, format) ফেরত দেয়। না থাকলে (None, None)।""" if not token or '/' in token or '\\' in token or '..' in token: return None, None path = os.path.join(_pending_thumbs_dir(), token) if not os.path.exists(path): return None, None fmt = token.rsplit('.', 1)[-1] if '.' in token else 'jpeg' with open(path, 'rb') as f: return f.read(), fmt def delete_pending_thumbnail(token): """Pending thumbnail মুছে দেয় (commit বা বাতিলের পর)।""" if not token or '/' in token or '\\' in token or '..' in token: return path = os.path.join(_pending_thumbs_dir(), token) if os.path.exists(path): try: os.remove(path) except OSError: pass # ============================================ # Delete operations # ============================================ def delete_project_media(project_id): """প্রজেক্টের পুরো media ফোল্ডার মুছে দেয় (প্রজেক্ট ডিলিটের সময়)।""" proj_dir = get_project_dir(project_id) if os.path.exists(proj_dir): try: shutil.rmtree(proj_dir) print(f"🗑️ Deleted media folder: {proj_dir}") return True except OSError as e: print(f"⚠️ Failed to delete media folder {proj_dir}: {e}") return False return False def delete_file(relative_path): """একটা নির্দিষ্ট ফাইল মুছে দেয়।""" abs_path = _safe_abs_path(relative_path) if abs_path and os.path.exists(abs_path): try: os.remove(abs_path) return True except OSError: return False return False