diff --git a/nginx/assets_daarion_space.conf b/nginx/assets_daarion_space.conf new file mode 100644 index 00000000..8b92c513 --- /dev/null +++ b/nginx/assets_daarion_space.conf @@ -0,0 +1,52 @@ +# NGINX configuration for assets.daarion.space +# Proxies requests to MinIO S3-compatible storage + +server { + server_name assets.daarion.space; + + # MinIO S3 API proxy + location / { + proxy_pass http://127.0.0.1:9000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Allow large file uploads + client_max_body_size 100M; + + # CORS headers for public assets + add_header Access-Control-Allow-Origin * always; + add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; + add_header Access-Control-Allow-Headers "Range, Content-Type" always; + + # Cache static assets + proxy_cache_valid 200 302 1d; + proxy_cache_valid 404 1h; + add_header Cache-Control "public, max-age=86400" always; + } + + # Health check endpoint + location /minio/health/live { + proxy_pass http://127.0.0.1:9000/minio/health/live; + access_log off; + } + + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/assets.daarion.space/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/assets.daarion.space/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} + +server { + if ($host = assets.daarion.space) { + return 301 https://$host$request_uri; + } + + listen 80; + server_name assets.daarion.space; + return 404; +} + diff --git a/scripts/migrate_assets_to_minio.py b/scripts/migrate_assets_to_minio.py new file mode 100755 index 00000000..9f72f05c --- /dev/null +++ b/scripts/migrate_assets_to_minio.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +Міграція assets з локального /static/uploads в MinIO +""" +import os +import sys +import mimetypes +from pathlib import Path +from datetime import datetime +from uuid import uuid4 + +# Додати шлях до city-service для імпорту +sys.path.insert(0, str(Path(__file__).parent.parent / "services" / "city-service")) + +try: + from lib.assets_client import upload_asset, ensure_bucket, client, settings +except ImportError as e: + print(f"❌ Помилка імпорту: {e}") + print("Переконайтеся що ви в правильній директорії та MinIO клієнт налаштований") + sys.exit(1) + +def get_content_type(file_path: str) -> str: + """Визначає content-type файлу""" + mime_type, _ = mimetypes.guess_type(file_path) + return mime_type or "application/octet-stream" + +def determine_prefix(file_path: str) -> str: + """Визначає префікс для MinIO на основі шляху файлу""" + path = Path(file_path) + name_lower = path.name.lower() + + # Логотипи MicroDAO + if "logo" in name_lower or "microdao" in str(path.parent).lower(): + return "microdao/logo" + + # Баннери MicroDAO + if "banner" in name_lower: + return "microdao/banner" + + # Аватари агентів + if "avatar" in name_lower or "agent" in name_lower: + return "agents/avatar" + + # Кімнати + if "room" in name_lower: + if "logo" in name_lower: + return "rooms/logo" + if "banner" in name_lower: + return "rooms/banner" + + # За замовчуванням - misc + return "misc" + +def migrate_file(local_path: str, dry_run: bool = False) -> tuple[str, str] | None: + """ + Мігрує один файл в MinIO + Повертає (old_path, new_url) або None якщо помилка + """ + try: + content_type = get_content_type(local_path) + prefix = determine_prefix(local_path) + + if dry_run: + print(f" [DRY RUN] Would upload: {local_path} -> {prefix}/...") + return (local_path, f"https://assets.daarion.space/{prefix}/migrated/{Path(local_path).name}") + + # Читаємо файл + with open(local_path, "rb") as f: + # Завантажуємо в MinIO + new_url = upload_asset(f, content_type, prefix) + print(f" ✅ Uploaded: {local_path} -> {new_url}") + return (local_path, new_url) + + except Exception as e: + print(f" ❌ Error migrating {local_path}: {e}") + return None + +def migrate_directory(source_dir: str, dry_run: bool = False) -> dict: + """ + Мігрує всі файли з директорії + Повертає статистику: {"total": N, "success": M, "failed": K, "mapping": {...}} + """ + source_path = Path(source_dir) + if not source_path.exists(): + print(f"❌ Директорія не існує: {source_dir}") + return {"total": 0, "success": 0, "failed": 0, "mapping": {}} + + # Знаходимо всі файли (виключаємо .git, __pycache__ тощо) + files = [] + for ext in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"]: + files.extend(source_path.rglob(f"*{ext}")) + files.extend(source_path.rglob(f"*{ext.upper()}")) + + print(f"📦 Знайдено {len(files)} файлів для міграції") + + if dry_run: + print("🔍 DRY RUN MODE - файли не будуть завантажені") + + # Переконаємося що bucket існує + if not dry_run: + ensure_bucket() + print(f"✅ Bucket '{settings.assets_bucket}' готовий") + + # Мігруємо файли + stats = {"total": len(files), "success": 0, "failed": 0, "mapping": {}} + + for file_path in files: + result = migrate_file(str(file_path), dry_run) + if result: + old_path, new_url = result + stats["mapping"][old_path] = new_url + stats["success"] += 1 + else: + stats["failed"] += 1 + + return stats + +def main(): + import argparse + + parser = argparse.ArgumentParser(description="Міграція assets в MinIO") + parser.add_argument( + "--source", + default="/opt/microdao-daarion/services/city-service/static/uploads", + help="Директорія з assets для міграції" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Тільки показати що буде зроблено, не завантажувати" + ) + args = parser.parse_args() + + print("🚀 Початок міграції assets в MinIO") + print(f"📁 Source: {args.source}") + print(f"🌐 MinIO Endpoint: {settings.minio_endpoint}") + print(f"🪣 Bucket: {settings.assets_bucket}") + print(f"🔗 Public URL: {settings.assets_public_base_url}") + print() + + stats = migrate_directory(args.source, dry_run=args.dry_run) + + print() + print("=" * 60) + print("📊 Статистика міграції:") + print(f" Total: {stats['total']}") + print(f" ✅ Success: {stats['success']}") + print(f" ❌ Failed: {stats['failed']}") + print("=" * 60) + + if not args.dry_run and stats["mapping"]: + # Зберігаємо маппінг для оновлення БД + mapping_file = Path(__file__).parent / "assets_migration_mapping.json" + import json + with open(mapping_file, "w") as f: + json.dump(stats["mapping"], f, indent=2) + print(f"💾 Маппінг збережено в: {mapping_file}") + print("⚠️ Потрібно оновити URLs в базі даних використовуючи цей маппінг") + +if __name__ == "__main__": + main() + diff --git a/scripts/update_db_asset_urls.py b/scripts/update_db_asset_urls.py new file mode 100755 index 00000000..812e0042 --- /dev/null +++ b/scripts/update_db_asset_urls.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Оновлює URLs assets в базі даних після міграції в MinIO +""" +import json +import sys +import asyncio +from pathlib import Path + +# Додати шлях до city-service +sys.path.insert(0, str(Path(__file__).parent.parent / "services" / "city-service")) + +try: + import asyncpg + from dotenv import load_dotenv + import os + + load_dotenv() +except ImportError as e: + print(f"❌ Помилка імпорту: {e}") + sys.exit(1) + +async def update_database(mapping_file: str, dry_run: bool = False): + """ + Оновлює URLs в базі даних на основі маппінгу + """ + # Читаємо маппінг + with open(mapping_file, "r") as f: + mapping = json.load(f) + + print(f"📖 Завантажено {len(mapping)} маппінгів") + + # Підключаємося до БД + database_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/daarion") + conn = await asyncpg.connect(database_url) + + try: + updates = { + "microdaos": {"logo_url": 0, "banner_url": 0}, + "agents": {"avatar_url": 0}, + "city_rooms": {"logo_url": 0, "banner_url": 0}, + } + + # Створюємо зворотний маппінг: старий URL -> новий URL + reverse_mapping = {} + for old_path, new_url in mapping.items(): + # Витягуємо відносний шлях з старого шляху + if "/static/uploads" in old_path: + relative_path = old_path.split("/static/uploads/")[-1] + # Створюємо старий URL як він міг бути в БД + old_urls = [ + f"/static/uploads/{relative_path}", + f"/api/static/{relative_path}", + f"static/uploads/{relative_path}", + relative_path, + ] + for old_url in old_urls: + reverse_mapping[old_url] = new_url + + print(f"🔄 Створено {len(reverse_mapping)} зворотних маппінгів") + + # Оновлюємо microdaos + for old_url, new_url in reverse_mapping.items(): + # Logo URLs + result = await conn.execute( + """ + UPDATE microdaos + SET logo_url = $1 + WHERE logo_url = $2 OR logo_url LIKE $3 + """, + new_url, + old_url, + f"%{old_url}%", + ) + if result != "UPDATE 0": + updates["microdaos"]["logo_url"] += int(result.split()[-1]) + if not dry_run: + print(f" ✅ Updated microdao logo: {old_url} -> {new_url}") + + # Banner URLs + result = await conn.execute( + """ + UPDATE microdaos + SET banner_url = $1 + WHERE banner_url = $2 OR banner_url LIKE $3 + """, + new_url, + old_url, + f"%{old_url}%", + ) + if result != "UPDATE 0": + updates["microdaos"]["banner_url"] += int(result.split()[-1]) + if not dry_run: + print(f" ✅ Updated microdao banner: {old_url} -> {new_url}") + + # Оновлюємо agents + for old_url, new_url in reverse_mapping.items(): + result = await conn.execute( + """ + UPDATE agents + SET avatar_url = $1 + WHERE avatar_url = $2 OR avatar_url LIKE $3 + """, + new_url, + old_url, + f"%{old_url}%", + ) + if result != "UPDATE 0": + updates["agents"]["avatar_url"] += int(result.split()[-1]) + if not dry_run: + print(f" ✅ Updated agent avatar: {old_url} -> {new_url}") + + # Оновлюємо city_rooms (якщо таблиця існує) + try: + for old_url, new_url in reverse_mapping.items(): + # Logo + result = await conn.execute( + """ + UPDATE city_rooms + SET logo_url = $1 + WHERE logo_url = $2 OR logo_url LIKE $3 + """, + new_url, + old_url, + f"%{old_url}%", + ) + if result != "UPDATE 0": + updates["city_rooms"]["logo_url"] += int(result.split()[-1]) + + # Banner + result = await conn.execute( + """ + UPDATE city_rooms + SET banner_url = $1 + WHERE banner_url = $2 OR banner_url LIKE $3 + """, + new_url, + old_url, + f"%{old_url}%", + ) + if result != "UPDATE 0": + updates["city_rooms"]["banner_url"] += int(result.split()[-1]) + except Exception as e: + print(f" ⚠️ city_rooms update skipped: {e}") + + print() + print("=" * 60) + print("📊 Статистика оновлень:") + for table, fields in updates.items(): + total = sum(fields.values()) + if total > 0: + print(f" {table}:") + for field, count in fields.items(): + if count > 0: + print(f" - {field}: {count}") + print("=" * 60) + + if dry_run: + print("🔍 DRY RUN - зміни не збережені") + else: + print("✅ База даних оновлена") + + finally: + await conn.close() + +def main(): + import argparse + + parser = argparse.ArgumentParser(description="Оновлення URLs assets в БД") + parser.add_argument( + "--mapping", + default="scripts/assets_migration_mapping.json", + help="Файл з маппінгом старих -> нових URLs" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Тільки показати що буде оновлено" + ) + args = parser.parse_args() + + if not Path(args.mapping).exists(): + print(f"❌ Файл маппінгу не знайдено: {args.mapping}") + print("Спочатку запустіть migrate_assets_to_minio.py") + sys.exit(1) + + print("🔄 Оновлення URLs в базі даних") + print(f"📄 Mapping file: {args.mapping}") + print() + + asyncio.run(update_database(args.mapping, dry_run=args.dry_run)) + +if __name__ == "__main__": + main() +