feat(assets): Add NGINX config and migration scripts for MinIO assets
- Add NGINX reverse proxy config for assets.daarion.space - Add script to migrate assets from /static/uploads to MinIO - Add script to update asset URLs in database after migration
This commit is contained in:
52
nginx/assets_daarion_space.conf
Normal file
52
nginx/assets_daarion_space.conf
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
162
scripts/migrate_assets_to_minio.py
Executable file
162
scripts/migrate_assets_to_minio.py
Executable file
@@ -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()
|
||||||
|
|
||||||
195
scripts/update_db_asset_urls.py
Executable file
195
scripts/update_db_asset_urls.py
Executable file
@@ -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()
|
||||||
|
|
||||||
Reference in New Issue
Block a user