diff --git a/apps/web/src/lib/utils/assetUrl.ts b/apps/web/src/lib/utils/assetUrl.ts index 6a4e9501..fb599e10 100644 --- a/apps/web/src/lib/utils/assetUrl.ts +++ b/apps/web/src/lib/utils/assetUrl.ts @@ -1,13 +1,8 @@ /** * Normalize asset URL for display. - * This is the SINGLE source of truth for building static asset URLs. * - * Handles various URL formats from the backend: - * - /api/static/uploads/... - already correct, return as-is - * - /assets/... - static assets in public folder, return as-is - * - /static/uploads/... - needs /api prefix - * - https://... or http://... - external URL, return as-is - * - uploads/... or static/uploads/... - relative path, needs /api/static prefix + * After migration to MinIO/S3, most assets will be full HTTPS URLs. + * This function handles both new S3 URLs and legacy local file paths. * * IMPORTANT: Do NOT manually concatenate /api/static anywhere in the codebase. * Always use this function instead. @@ -15,45 +10,33 @@ export function normalizeAssetUrl(url: string | null | undefined): string | null { if (!url) return null; - // External URLs - return as-is + // Full HTTPS/HTTP URLs (from MinIO/S3) - return as-is if (url.startsWith('http://') || url.startsWith('https://')) { return url; } - // Already correct format with /api/static + // Legacy: Already correct format with /api/static if (url.startsWith('/api/static')) { return url; } - // Static assets in public folder (/assets/...) + // Legacy: Static assets in public folder (/assets/...) if (url.startsWith('/assets/')) { return url; } - // Old format with /static/ prefix - add /api + // Legacy: Old format with /static/ prefix - add /api if (url.startsWith('/static/')) { return `/api${url}`; } - // Relative path without leading slash (uploads/..., static/...) - // Remove any duplicate prefixes and normalize - let cleaned = url; - - // Remove leading /api/static if somehow duplicated - cleaned = cleaned.replace(/^\/api\/static\//, ''); - // Remove leading /static/ - cleaned = cleaned.replace(/^\/static\//, ''); - // Remove leading static/ (no slash) - cleaned = cleaned.replace(/^static\//, ''); - // Remove leading slash - cleaned = cleaned.replace(/^\/+/, ''); - - // If it looks like a relative upload path, prefix with /api/static/ - if (cleaned.startsWith('uploads/') || cleaned.includes('/')) { + // Legacy: Relative paths - normalize to /api/static/ + let cleaned = url.replace(/^\/+/, ''); // Remove leading slashes + if (cleaned && !cleaned.startsWith('http')) { return `/api/static/${cleaned}`; } - // Unknown format - return as-is (might be a simple filename) + // Unknown format - return as-is return url; } diff --git a/docker-compose.db.yml b/docker-compose.db.yml new file mode 100644 index 00000000..98dcbfcb --- /dev/null +++ b/docker-compose.db.yml @@ -0,0 +1,83 @@ +version: "3.9" + +services: + # PostgreSQL Database with persistent storage + db: + image: postgres:15-alpine + container_name: daarion-postgres + restart: unless-stopped + environment: + POSTGRES_DB: daarion + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./migrations:/docker-entrypoint-initdb.d:ro + networks: + - dagi-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d daarion"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + # Automatic database backups + db-backup: + image: prodrigestivill/postgres-backup-local:latest + container_name: daarion-db-backup + restart: unless-stopped + environment: + POSTGRES_HOST: db + POSTGRES_DB: daarion + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + SCHEDULE: "@every 12h" + BACKUP_KEEP_DAYS: 7 + BACKUP_KEEP_WEEKS: 4 + BACKUP_KEEP_MONTHS: 6 + volumes: + - ./db_backups:/backups + depends_on: + - db + networks: + - dagi-network + + # MinIO S3-compatible object storage for assets + minio: + image: minio/minio:latest + container_name: daarion-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-assets-admin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-change-me-strong-password} + ports: + - "9000:9000" # S3 API + - "9001:9001" # Web console + volumes: + - minio_data:/data + networks: + - dagi-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +volumes: + pgdata: + driver: local + name: daarion_pgdata + minio_data: + driver: local + name: daarion_minio_data + +networks: + dagi-network: + external: true + name: dagi-network + diff --git a/docs/DB_RESTORE.md b/docs/DB_RESTORE.md new file mode 100644 index 00000000..0416f00d --- /dev/null +++ b/docs/DB_RESTORE.md @@ -0,0 +1,138 @@ +# DB_RESTORE — Як відновити базу daarion + +## 1. Відновлення з останнього бекапу + +### Крок 1: Зупинити сервіси + +```bash +docker compose -f docker-compose.db.yml stop db +docker compose -f docker-compose.web.yml stop city-service web +``` + +### Крок 2: Відновити базу з бекапу + +```bash +# Знайти останній бекап +ls -lt db_backups/ | head -5 + +# Відновити базу +docker compose -f docker-compose.db.yml exec db psql -U postgres -d postgres -c "DROP DATABASE IF EXISTS daarion;" +docker compose -f docker-compose.db.yml exec db psql -U postgres -d postgres -c "CREATE DATABASE daarion;" +docker compose -f docker-compose.db.yml exec -T db psql -U postgres -d daarion < db_backups/daarion_YYYY-MM-DD_HH-MM.sql +``` + +Або через docker exec: + +```bash +# Скопіювати бекап в контейнер +docker cp db_backups/daarion_2025-12-02_09-00.sql daarion-postgres:/tmp/backup.sql + +# Відновити +docker exec daarion-postgres psql -U postgres -d postgres -c "DROP DATABASE IF EXISTS daarion;" +docker exec daarion-postgres psql -U postgres -d postgres -c "CREATE DATABASE daarion;" +docker exec -i daarion-postgres psql -U postgres -d daarion < /tmp/backup.sql +``` + +### Крок 3: Перезапустити сервіси + +```bash +docker compose -f docker-compose.db.yml up -d db +docker compose -f docker-compose.web.yml up -d city-service web +``` + +### Крок 4: Перевірити + +```bash +# Перевірити що база працює +docker exec daarion-postgres psql -U postgres -d daarion -c "SELECT COUNT(*) FROM microdao;" + +# Перевірити API +curl https://daarion.space/api/city/microdao?limit=1 +``` + +--- + +## 2. Якщо бекапу немає (аварійне відновлення) + +### Крок 1: Застосувати міграції + +```bash +cd /opt/microdao-daarion +for f in migrations/*.sql; do + echo "Applying: $f" + docker exec -i daarion-postgres psql -U postgres -d daarion < "$f" +done +``` + +### Крок 2: Запустити seed-скрипт + +```bash +python3 scripts/seed_full_city_reset.py +``` + +### Крок 3: Відновити DAGI-агентів NODE2 + +```bash +python3 scripts/sync-node2-dagi-agents.py +``` + +### Крок 4: Перевірити + +```bash +# Перевірити MicroDAOs +docker exec daarion-postgres psql -U postgres -d daarion -c "SELECT slug, name FROM microdao;" + +# Перевірити агентів +docker exec daarion-postgres psql -U postgres -d daarion -c "SELECT COUNT(*) FROM agents;" +``` + +--- + +## 3. Перевірка бекапів + +### Список доступних бекапів + +```bash +ls -lh db_backups/ +``` + +### Структура бекапів + +- `daarion_YYYY-MM-DD_HH-MM.sql` - щоденні бекапи (зберігаються 7 днів) +- `daarion_YYYY-MM-DD_HH-MM.sql` - тижневі бекапи (зберігаються 4 тижні) +- `daarion_YYYY-MM-DD_HH-MM.sql` - місячні бекапи (зберігаються 6 місяців) + +### Створити бекап вручну + +```bash +docker exec daarion-postgres pg_dump -U postgres daarion > db_backups/manual_$(date +%F_%H-%M).sql +``` + +--- + +## 4. Troubleshooting + +### Проблема: "database does not exist" + +```bash +# Створити базу +docker exec daarion-postgres psql -U postgres -d postgres -c "CREATE DATABASE daarion;" +``` + +### Проблема: "connection refused" + +```bash +# Перевірити статус контейнера +docker ps | grep postgres + +# Перезапустити +docker compose -f docker-compose.db.yml restart db +``` + +### Проблема: "permission denied" при відновленні + +```bash +# Перевірити права на файл бекапу +chmod 644 db_backups/daarion_*.sql +``` + diff --git a/docs/INFRA_ASSETS_MINIO.md b/docs/INFRA_ASSETS_MINIO.md new file mode 100644 index 00000000..597576e0 --- /dev/null +++ b/docs/INFRA_ASSETS_MINIO.md @@ -0,0 +1,389 @@ +# INFRA_ASSETS_MINIO — Налаштування MinIO для Assets + +## Огляд + +DAARION використовує **MinIO** (S3-compatible object storage) для зберігання assets: +- Логотипи MicroDAO +- Банери MicroDAO +- Аватарки агентів +- Інші статичні файли + +**Переваги:** +- Assets не залежать від локального сервера +- Можливість реплікації на зовнішній S3/R2 +- Масштабованість +- Простота backup/restore + +--- + +## 1. Архітектура + +``` +┌─────────────┐ +│ Frontend │ → https://assets.daarion.space/daarion-assets/... +└─────────────┘ + │ + ↓ +┌─────────────┐ +│ Caddy │ → Reverse proxy +│ / NGINX │ +└─────────────┘ + │ + ↓ +┌─────────────┐ +│ MinIO │ → S3 API (port 9000) +│ (Docker) │ → Console (port 9001) +└─────────────┘ + │ + ↓ +┌─────────────┐ +│ Volume │ → minio_data (persistent) +└─────────────┘ +``` + +--- + +## 2. Docker Compose конфігурація + +**Файл:** `docker-compose.db.yml` + +```yaml +services: + minio: + image: minio/minio:latest + container_name: daarion-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + ports: + - "9000:9000" # S3 API + - "9001:9001" # Web console + volumes: + - minio_data:/data +``` + +**ENV змінні (.env):** +```env +MINIO_ROOT_USER=assets-admin +MINIO_ROOT_PASSWORD=very-strong-password +ASSETS_BUCKET=daarion-assets +ASSETS_PUBLIC_BASE_URL=https://assets.daarion.space/daarion-assets +MINIO_ENDPOINT=http://minio:9000 +``` + +--- + +## 3. Початкове налаштування + +### Крок 1: Запустити MinIO + +```bash +docker compose -f docker-compose.db.yml up -d minio +``` + +### Крок 2: Відкрити консоль + +Відкрити в браузері: `http://localhost:9001` (або `https://minio.daarion.space`) + +Логін: +- Username: `assets-admin` (з .env) +- Password: `very-strong-password` (з .env) + +### Крок 3: Створити bucket + +1. Натиснути "Create Bucket" +2. Назва: `daarion-assets` +3. Region: залишити за замовчуванням +4. Натиснути "Create Bucket" + +### Крок 4: Встановити public read policy + +1. Відкрити bucket `daarion-assets` +2. Перейти в "Access Policy" +3. Вибрати "Public" або "Custom" +4. Для Custom policy: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::daarion-assets/*"] + } + ] +} +``` + +--- + +## 4. DNS налаштування + +### A/AAAA записи + +``` +assets.daarion.space → IP NODE1 (для публічного доступу) +minio.daarion.space → IP NODE1 (опційно, для консолі) +``` + +--- + +## 5. Reverse Proxy (Caddy) + +**Файл:** `Caddyfile` + +```caddy +# Assets public access +assets.daarion.space { + encode gzip + + reverse_proxy minio:9000 { + header_up Host {upstream_hostport} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +# MinIO console (опційно) +minio.daarion.space { + encode gzip + + reverse_proxy minio:9001 { + header_up Host {upstream_hostport} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} +``` + +**Docker Compose:** +```yaml + caddy: + image: caddy:2 + container_name: daarion-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - minio +``` + +--- + +## 6. Reverse Proxy (NGINX) + +**Файл:** `nginx.conf` (фрагмент) + +```nginx +http { + upstream minio_api { + server minio:9000; + } + + server { + listen 80; + server_name assets.daarion.space; + + client_max_body_size 100M; + + location / { + proxy_pass http://minio_api; + proxy_set_header Host $http_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; + } + } +} +``` + +--- + +## 7. Формат URL в БД + +Після завантаження файлу через API, в БД зберігається повний HTTPS URL: + +``` +https://assets.daarion.space/daarion-assets/microdao/logo/2025/12/02/abc123def456.png +``` + +**Структура:** +- `https://assets.daarion.space` - public domain +- `/daarion-assets` - bucket name +- `/microdao/logo` - prefix (тип asset) +- `/2025/12/02` - дата завантаження +- `/abc123def456.png` - унікальний ID + розширення + +--- + +## 8. Backend інтеграція + +### Upload endpoint (приклад) + +```python +from fastapi import UploadFile, File +from lib.assets_client import upload_asset + +@router.post("/microdao/{slug}/logo") +async def upload_logo(slug: str, file: UploadFile = File(...)): + url = upload_asset( + file.file, + file.content_type, + prefix="microdao/logo", + filename=file.filename + ) + # Зберегти url в БД + await repo.update_logo(slug=slug, logo_url=url) + return {"logo_url": url} +``` + +### Клієнт MinIO + +**Файл:** `services/city-service/lib/assets_client.py` + +Функції: +- `upload_asset()` - завантажити файл, повернути URL +- `delete_asset()` - видалити файл +- `ensure_bucket()` - переконатися що bucket існує + +--- + +## 9. Frontend інтеграція + +**Файл:** `apps/web/src/lib/utils/assetUrl.ts` + +```typescript +export function normalizeAssetUrl(url?: string | null): string | null { + if (!url) return null; + + // Full HTTPS URLs (from MinIO) - return as-is + if (url.startsWith('https://') || url.startsWith('http://')) { + return url; + } + + // Legacy local paths - handle fallback + // ... +} +``` + +--- + +## 10. Backup MinIO + +### Створити backup bucket + +```bash +# Використовуючи MinIO Client (mc) +mc alias set local http://localhost:9000 assets-admin +mc mirror local/daarion-assets ./backups/minio/ +``` + +### Автоматичний backup (cron) + +```bash +# Додати в crontab +0 2 * * * docker exec daarion-minio mc mirror minio/daarion-assets /backups/minio/$(date +\%F) +``` + +--- + +## 11. Міграція з локальних файлів + +### Крок 1: Завантажити існуючі файли в MinIO + +```bash +# Використовуючи mc +mc cp ./services/city-service/static/uploads/microdao/logo/* local/daarion-assets/microdao/logo/ +mc cp ./services/city-service/static/uploads/microdao/banner/* local/daarion-assets/microdao/banner/ +``` + +### Крок 2: Оновити URL в БД + +```sql +UPDATE microdao +SET logo_url = REPLACE(logo_url, '/api/static/uploads/', 'https://assets.daarion.space/daarion-assets/') +WHERE logo_url LIKE '/api/static/uploads/%'; +``` + +--- + +## 12. Troubleshooting + +### Проблема: "Access Denied" + +Перевірити: +1. Bucket має public read policy +2. URL правильний (включає bucket name) +3. DNS налаштований правильно + +### Проблема: "Connection refused" + +```bash +# Перевірити статус +docker ps | grep minio + +# Перезапустити +docker compose -f docker-compose.db.yml restart minio +``` + +### Проблема: "Bucket does not exist" + +```bash +# Створити через mc +mc mb local/daarion-assets + +# Або через консоль MinIO +``` + +--- + +## 13. Моніторинг + +### Перевірка використання диска + +```bash +docker exec daarion-minio du -sh /data +``` + +### Перевірка кількості об'єктів + +Через MinIO Console → Bucket → Statistics + +--- + +## 14. Реплікація на зовнішній S3 (опційно) + +Для додаткової надійності можна налаштувати реплікацію на AWS S3 або Cloudflare R2: + +```bash +# Налаштувати remote +mc alias set s3 https://s3.amazonaws.com ACCESS_KEY SECRET_KEY + +# Налаштувати реплікацію +mc replicate add local/daarion-assets --remote-bucket s3/daarion-assets-backup +``` + +--- + +## 15. Чекліст налаштування + +- [ ] MinIO запущений (`docker ps | grep minio`) +- [ ] Bucket `daarion-assets` створений +- [ ] Public read policy встановлена +- [ ] DNS `assets.daarion.space` налаштований +- [ ] Caddy/NGINX проксує запити до MinIO +- [ ] Backend використовує `assets_client.py` +- [ ] Frontend відображає assets з HTTPS URLs +- [ ] Тестовий upload працює + diff --git a/docs/SEED_RECOVERY.md b/docs/SEED_RECOVERY.md new file mode 100644 index 00000000..2bf28ea0 --- /dev/null +++ b/docs/SEED_RECOVERY.md @@ -0,0 +1,230 @@ +# SEED_RECOVERY — Аварійне відновлення міста + +## Коли використовувати + +Цей процес використовується коли: +- База даних була повністю втрачена +- Немає доступних бекапів +- Потрібно швидко відновити мінімально робочий стан DAARION City + +--- + +## Крок 1: Переконатися що база порожня + +```bash +# Перевірити чи є дані +docker exec daarion-postgres psql -U postgres -d daarion -c "SELECT COUNT(*) FROM microdao;" + +# Якщо є дані - очистити (ОБЕРЕЖНО!) +docker exec daarion-postgres psql -U postgres -d daarion -c "TRUNCATE TABLE microdao, agents, city_rooms CASCADE;" +``` + +--- + +## Крок 2: Застосувати міграції + +```bash +cd /opt/microdao-daarion + +# Застосувати всі міграції +for f in migrations/*.sql; do + echo "Applying: $f" + docker exec -i daarion-postgres psql -U postgres -d daarion < "$f" 2>&1 | grep -v "ERROR\|NOTICE" || true +done +``` + +--- + +## Крок 3: Запустити seed-скрипт + +```bash +# Встановити змінні оточення +export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daarion" + +# Запустити seed +python3 scripts/seed_full_city_reset.py +``` + +Очікуваний вивід: +``` +🏙️ DAARION City Emergency Recovery +============================================================ + +🔗 Connecting to database... +✅ Connected + +📦 Seeding MicroDAOs... + ✅ DAARION DAO + ✅ Energy Union + ✅ GreenFood DAO + ✅ Soul Retreat Hub +✅ Seeded 4 MicroDAOs + +🤖 Seeding core agents (NODE1)... + ✅ DAARWIZZ + ✅ Helion + ✅ GreenFood Bot + ✅ Soul Bot +✅ Seeded 4 core agents + +🔗 Linking agents to MicroDAOs... + ✅ Linked daarwizz → dao_daarion + ✅ Linked helion → dao_energy + ✅ Linked greenfood → dao_greenfood + ✅ Linked soul → dao_soul +✅ Linked agents to MicroDAOs + +============================================================ +✅ Recovery complete! + MicroDAOs: 4 + Agents: 4 + +📝 Next steps: + 1. Run migrations if needed + 2. Run scripts/sync-node2-dagi-agents.py for NODE2 agents + 3. Upload logos/banners to MinIO if not already done +============================================================ +``` + +--- + +## Крок 4: Відновити DAGI-агентів NODE2 + +```bash +python3 scripts/sync-node2-dagi-agents.py +``` + +Це додасть ~50 DAGI-агентів для NODE2 з `config/agents_city_mapping.yaml`. + +--- + +## Крок 5: Перевірити відновлення + +### Перевірка MicroDAOs + +```bash +docker exec daarion-postgres psql -U postgres -d daarion -c " + SELECT slug, name, district, is_platform + FROM microdao + ORDER BY pinned_weight; +" +``` + +Очікуваний результат: +``` + slug | name | district | is_platform +--------------+-----------------+----------+------------- + daarion | DAARION DAO | Core | t + energy-union | Energy Union | Energy | t + greenfood | GreenFood DAO | Green | t + soul-retreat | Soul Retreat Hub| Soul | t +``` + +### Перевірка агентів + +```bash +docker exec daarion-postgres psql -U postgres -d daarion -c " + SELECT COUNT(*) as total, + COUNT(*) FILTER (WHERE node_id = 'node-1-hetzner-gex44') as node1, + COUNT(*) FILTER (WHERE node_id = 'node-2-macbook-m4max') as node2 + FROM agents; +" +``` + +### Перевірка через API + +```bash +# MicroDAOs +curl -s "https://daarion.space/api/city/microdao?limit=4" | python3 -m json.tool | head -20 + +# Agents +curl -s "https://daarion.space/api/city/agents?limit=5" | python3 -m json.tool | head -20 +``` + +--- + +## Крок 6: Завантажити логотипи/банери в MinIO (якщо потрібно) + +Якщо логотипи ще не завантажені в MinIO: + +1. Відкрити MinIO Console: `http://localhost:9001` (або `https://minio.daarion.space`) +2. Створити bucket `daarion-assets` (якщо не існує) +3. Завантажити файли: + - `microdao/logo/daarion.png` + - `microdao/logo/energy-union.png` + - `microdao/logo/greenfood.png` + - `microdao/logo/soul-retreat.png` +4. Встановити public read policy для bucket + +Або використати MinIO CLI: + +```bash +# Встановити mc (MinIO Client) +# https://min.io/docs/minio/linux/reference/minio-mc.html + +# Налаштувати alias +mc alias set local http://localhost:9000 assets-admin + +# Завантажити файли +mc cp ./assets/logos/daarion.png local/daarion-assets/microdao/logo/daarion.png +mc cp ./assets/logos/energy-union.png local/daarion-assets/microdao/logo/energy-union.png +# ... і т.д. +``` + +--- + +## Що відновлюється + +### ✅ Відновлюється seed-скриптом: + +- 4 базові MicroDAOs (DAARION, Energy Union, GreenFood, Soul) +- 4 core агентів NODE1 (DAARWIZZ, Helion, GreenFood Bot, Soul Bot) +- Зв'язки агентів з MicroDAOs + +### ❌ НЕ відновлюється автоматично: + +- City Rooms (потрібно запустити `scripts/seed_city_rooms.py`) +- DAGI-агенти NODE2 (потрібно запустити `scripts/sync-node2-dagi-agents.py`) +- Node cache дані (відновляться через heartbeat) +- Логотипи/банери файли (потрібно завантажити в MinIO) + +--- + +## Швидкий чекліст + +- [ ] База даних створена та порожня +- [ ] Всі міграції застосовані +- [ ] `seed_full_city_reset.py` виконано успішно +- [ ] `sync-node2-dagi-agents.py` виконано (для NODE2) +- [ ] Логотипи завантажені в MinIO +- [ ] API повертає дані +- [ ] Frontend відображає MicroDAOs та агентів + +--- + +## Troubleshooting + +### Помилка: "relation does not exist" + +Міграції не застосовані. Запустити Крок 2. + +### Помилка: "duplicate key value" + +Дані вже існують. Seed-скрипт використовує `ON CONFLICT`, тому це нормально. + +### Помилка: "connection refused" + +PostgreSQL не запущений: +```bash +docker compose -f docker-compose.db.yml up -d db +``` + +### Логотипи не відображаються + +1. Перевірити що файли в MinIO +2. Перевірити що bucket має public read policy +3. Перевірити що URL в БД правильні: +```bash +docker exec daarion-postgres psql -U postgres -d daarion -c "SELECT slug, logo_url FROM microdao;" +``` + diff --git a/docs/tasks/TASK_PHASE_DATABASE_HARDENING_AND_ASSETS_MIGRATION_v1.md b/docs/tasks/TASK_PHASE_DATABASE_HARDENING_AND_ASSETS_MIGRATION_v1.md new file mode 100644 index 00000000..2a57db99 --- /dev/null +++ b/docs/tasks/TASK_PHASE_DATABASE_HARDENING_AND_ASSETS_MIGRATION_v1.md @@ -0,0 +1,146 @@ +# TASK_PHASE_DATABASE_HARDENING_AND_ASSETS_MIGRATION_v1 + +**Статус:** TODO +**Пріоритет:** CRITICAL +**Мета:** Захистити базу даних від втрати та перенести assets на S3-compatible storage + +--- + +## 0. Проблема + +1. **База даних зникла** - PostgreSQL був пересозданий, всі дані втрачені +2. **Логотипи/банери зникли** - файли на диску є, але посилання в БД пропали +3. **Немає автоматичних бекапів** - немає механізму відновлення +4. **Assets залежать від локального сервера** - при втраті БД втрачаються посилання + +--- + +## 1. Цілі + +1. ✅ PostgreSQL → persistent Docker volume (не зникає при перезапуску) +2. ✅ Автоматичні бекапи БД кожні 12 годин +3. ✅ Assets (лого/банери/аватарки) → MinIO (S3-compatible) +4. ✅ Спрощений `normalizeAssetUrl` для S3 URLs +5. ✅ Seed-скрипт для аварійного відновлення міста + +--- + +## 2. Кроки виконання + +### 2.1. Міграція PostgreSQL → persistent storage + +**Файл:** `docker-compose.yml` або `docker-compose.db.yml` + +- [ ] Додати volume `pgdata:/var/lib/postgresql/data` до DB service +- [ ] Створити volume `pgdata` в секції volumes +- [ ] Перевірити що дані зберігаються після перезапуску + +### 2.2. Backup система БД + +**Файл:** `docker-compose.yml` + +- [ ] Додати service `db-backup` з `prodrigestivill/postgres-backup-local` +- [ ] Налаштувати `SCHEDULE: "@every 12h"` +- [ ] Налаштувати retention (7 днів, 4 тижні, 6 місяців) +- [ ] Створити директорію `db_backups/` для зберігання + +### 2.3. MinIO для assets storage + +**Файл:** `docker-compose.yml` + +- [ ] Додати service `minio` з `minio/minio:latest` +- [ ] Налаштувати volumes `minio_data:/data` +- [ ] Налаштувати environment variables (MINIO_ROOT_USER, MINIO_ROOT_PASSWORD) +- [ ] Відкрити порти 9000 (API) та 9001 (console) + +### 2.4. Бекенд: інтеграція з MinIO + +**Файли:** +- `services/city-service/config.py` - додати налаштування MinIO +- `services/city-service/lib/assets_client.py` - створити клієнт для MinIO +- `services/city-service/routes_city.py` - оновити upload endpoints + +- [ ] Додати залежність `minio` в `requirements.txt` +- [ ] Створити `assets_client.py` з функцією `upload_asset()` +- [ ] Оновити upload handlers для MicroDAO logo/banner +- [ ] Оновити upload handlers для Agent avatar +- [ ] Перевірити що URL зберігаються як повні HTTPS URLs + +### 2.5. Міграція БД для asset полів + +**Файл:** `migrations/043_asset_urls_to_text.sql` + +- [ ] Змінити тип `logo_url`, `banner_url`, `avatar_url` на `text` +- [ ] Додати коментарі про формат URL (HTTPS до MinIO) + +### 2.6. Frontend: спрощений normalizeAssetUrl + +**Файл:** `apps/web/src/lib/utils/assetUrl.ts` + +- [ ] Спростити функцію - тільки passthrough для HTTPS URLs +- [ ] Залишити fallback для старих `/api/static/` URLs +- [ ] Прибрати всю складну логіку нормалізації + +### 2.7. Seed-скрипт для аварійного відновлення + +**Файл:** `scripts/seed_full_city_reset.py` + +- [ ] Створити базові MicroDAO (DAARION, Energy Union, GreenFood, Soul) +- [ ] Додати райони міста +- [ ] Додати базові city rooms +- [ ] Додати core агентів NODE1 +- [ ] Використовувати S3 URLs для логотипів/банерів + +### 2.8. Документація + +**Файли:** +- `docs/DB_RESTORE.md` - інструкція по відновленню БД +- `docs/SEED_RECOVERY.md` - інструкція по аварійному відновленню +- `docs/INFRA_ASSETS_MINIO.md` - документація по MinIO setup + +- [ ] Створити DB_RESTORE.md з командами відновлення +- [ ] Створити SEED_RECOVERY.md з інструкціями +- [ ] Створити INFRA_ASSETS_MINIO.md з конфігурацією + +--- + +## 3. Перевірка після виконання + +- [ ] PostgreSQL дані зберігаються після `docker compose restart db` +- [ ] Бекапи створюються в `db_backups/` кожні 12 годин +- [ ] MinIO доступний на `http://localhost:9000` (API) та `http://localhost:9001` (console) +- [ ] Upload логотипу MicroDAO зберігається в MinIO та повертає HTTPS URL +- [ ] Frontend відображає логотипи з MinIO URLs +- [ ] Seed-скрипт відновлює базові дані міста + +--- + +## 4. ENV змінні + +Додати в `.env`: + +```env +# PostgreSQL +POSTGRES_PASSWORD=super-secret + +# MinIO +MINIO_ROOT_USER=assets-admin +MINIO_ROOT_PASSWORD=very-strong-password +ASSETS_BUCKET=daarion-assets +ASSETS_PUBLIC_BASE_URL=https://assets.daarion.space/daarion-assets +MINIO_ENDPOINT=http://minio:9000 +``` + +--- + +## 5. DNS налаштування + +- [ ] `assets.daarion.space` → IP NODE1 (для публічного доступу до assets) +- [ ] `minio.daarion.space` → IP NODE1 (опційно, для консолі) + +--- + +## 6. Caddy/NGINX конфігурація + +Див. `docs/INFRA_ASSETS_MINIO.md` для деталей налаштування reverse proxy. + diff --git a/migrations/043_asset_urls_to_text.sql b/migrations/043_asset_urls_to_text.sql new file mode 100644 index 00000000..9502e44e --- /dev/null +++ b/migrations/043_asset_urls_to_text.sql @@ -0,0 +1,33 @@ +-- 043_asset_urls_to_text.sql +-- Migration: Change asset URL fields to text to support full HTTPS URLs from MinIO/S3 + +-- MicroDAO logo and banner URLs +ALTER TABLE microdao + ALTER COLUMN logo_url TYPE text, + ALTER COLUMN banner_url TYPE text; + +COMMENT ON COLUMN microdao.logo_url IS 'Full HTTPS URL to logo image (e.g., https://assets.daarion.space/daarion-assets/microdao/logo/...)'; +COMMENT ON COLUMN microdao.banner_url IS 'Full HTTPS URL to banner image (e.g., https://assets.daarion.space/daarion-assets/microdao/banner/...)'; + +-- Agent avatar URLs +ALTER TABLE agents + ALTER COLUMN avatar_url TYPE text; + +COMMENT ON COLUMN agents.avatar_url IS 'Full HTTPS URL to avatar image (e.g., https://assets.daarion.space/daarion-assets/agents/avatar/...)'; + +-- City rooms logo and banner (if exists) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'city_rooms' AND column_name = 'logo_url') THEN + ALTER TABLE city_rooms ALTER COLUMN logo_url TYPE text; + COMMENT ON COLUMN city_rooms.logo_url IS 'Full HTTPS URL to room logo image'; + END IF; + + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'city_rooms' AND column_name = 'banner_url') THEN + ALTER TABLE city_rooms ALTER COLUMN banner_url TYPE text; + COMMENT ON COLUMN city_rooms.banner_url IS 'Full HTTPS URL to room banner image'; + END IF; +END $$; + diff --git a/scripts/seed_full_city_reset.py b/scripts/seed_full_city_reset.py new file mode 100644 index 00000000..31bc8e68 --- /dev/null +++ b/scripts/seed_full_city_reset.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +""" +Emergency recovery script for DAARION City. +Restores essential data after database loss: +- Core MicroDAOs +- City districts +- Basic city rooms +- Core agents for NODE1 +- Asset URLs (from MinIO/S3) +""" +import asyncio +import os +import sys +import asyncpg +from typing import List, Dict, Any + +# Add parent directory to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://postgres:postgres@localhost:5432/daarion" +) + +# Base MicroDAOs with S3 asset URLs +BASE_MICRODAO = [ + { + "id": "dao_daarion", + "slug": "daarion", + "name": "DAARION DAO", + "description": "Main ecosystem DAO", + "district": "Core", + "is_public": True, + "is_platform": True, + "is_active": True, + "is_pinned": True, + "pinned_weight": 1, + "orchestrator_agent_id": "daarwizz", + "logo_url": "https://assets.daarion.space/daarion-assets/microdao/logo/daarion.png", + "banner_url": None, + }, + { + "id": "dao_energy", + "slug": "energy-union", + "name": "Energy Union", + "description": "Energy optimization & sustainability", + "district": "Energy", + "is_public": True, + "is_platform": True, + "is_active": True, + "is_pinned": True, + "pinned_weight": 2, + "orchestrator_agent_id": "helion", + "logo_url": "https://assets.daarion.space/daarion-assets/microdao/logo/energy-union.png", + "banner_url": None, + }, + { + "id": "dao_greenfood", + "slug": "greenfood", + "name": "GreenFood DAO", + "description": "Sustainable food systems", + "district": "Green", + "is_public": True, + "is_platform": True, + "is_active": True, + "is_pinned": True, + "pinned_weight": 3, + "orchestrator_agent_id": "greenfood", + "logo_url": "https://assets.daarion.space/daarion-assets/microdao/logo/greenfood.png", + "banner_url": None, + }, + { + "id": "dao_soul", + "slug": "soul-retreat", + "name": "Soul Retreat Hub", + "description": "Identity & reputation system", + "district": "Soul", + "is_public": True, + "is_platform": True, + "is_active": True, + "is_pinned": True, + "pinned_weight": 4, + "orchestrator_agent_id": "soul", + "logo_url": "https://assets.daarion.space/daarion-assets/microdao/logo/soul-retreat.png", + "banner_url": None, + }, +] + +# Core agents for NODE1 +CORE_AGENTS_NODE1 = [ + { + "id": "daarwizz", + "display_name": "DAARWIZZ", + "kind": "orchestrator", + "role": "Core Orchestrator", + "node_id": "node-1-hetzner-gex44", + "home_node_id": "node-1-hetzner-gex44", + "district": "Core", + "is_public": True, + "is_orchestrator": True, + "status": "online", + }, + { + "id": "helion", + "display_name": "Helion", + "kind": "orchestrator", + "role": "Energy Orchestrator", + "node_id": "node-1-hetzner-gex44", + "home_node_id": "node-1-hetzner-gex44", + "district": "Energy", + "is_public": True, + "is_orchestrator": True, + "status": "online", + }, + { + "id": "greenfood", + "display_name": "GreenFood Bot", + "kind": "orchestrator", + "role": "Green Orchestrator", + "node_id": "node-1-hetzner-gex44", + "home_node_id": "node-1-hetzner-gex44", + "district": "Green", + "is_public": True, + "is_orchestrator": True, + "status": "online", + }, + { + "id": "soul", + "display_name": "Soul Bot", + "kind": "orchestrator", + "role": "Soul Orchestrator", + "node_id": "node-1-hetzner-gex44", + "home_node_id": "node-1-hetzner-gex44", + "district": "Soul", + "is_public": True, + "is_orchestrator": True, + "status": "online", + }, +] + + +async def seed_microdaos(conn: asyncpg.Connection): + """Seed base MicroDAOs.""" + print("📦 Seeding MicroDAOs...") + + for dao in BASE_MICRODAO: + await conn.execute(""" + INSERT INTO microdao ( + id, slug, name, description, district, + is_public, is_platform, is_active, is_pinned, pinned_weight, + orchestrator_agent_id, logo_url, banner_url + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 + ) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + district = EXCLUDED.district, + is_public = EXCLUDED.is_public, + is_platform = EXCLUDED.is_platform, + is_active = EXCLUDED.is_active, + is_pinned = EXCLUDED.is_pinned, + pinned_weight = EXCLUDED.pinned_weight, + orchestrator_agent_id = EXCLUDED.orchestrator_agent_id, + logo_url = EXCLUDED.logo_url, + banner_url = EXCLUDED.banner_url, + updated_at = NOW() + """, + dao["id"], dao["slug"], dao["name"], dao["description"], dao["district"], + dao["is_public"], dao["is_platform"], dao["is_active"], + dao["is_pinned"], dao["pinned_weight"], dao["orchestrator_agent_id"], + dao["logo_url"], dao["banner_url"] + ) + print(f" ✅ {dao['name']}") + + print(f"✅ Seeded {len(BASE_MICRODAO)} MicroDAOs") + + +async def seed_core_agents(conn: asyncpg.Connection): + """Seed core agents for NODE1.""" + print("🤖 Seeding core agents (NODE1)...") + + for agent in CORE_AGENTS_NODE1: + await conn.execute(""" + INSERT INTO agents ( + id, display_name, kind, role, node_id, home_node_id, + district, is_public, is_orchestrator, status, + slug, public_slug, public_title, public_tagline, + public_district, is_listed_in_directory + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16 + ) + ON CONFLICT (id) DO UPDATE SET + display_name = EXCLUDED.display_name, + kind = EXCLUDED.kind, + role = EXCLUDED.role, + node_id = EXCLUDED.node_id, + home_node_id = EXCLUDED.home_node_id, + district = EXCLUDED.district, + is_public = EXCLUDED.is_public, + is_orchestrator = EXCLUDED.is_orchestrator, + status = EXCLUDED.status, + updated_at = NOW() + """, + agent["id"], agent["display_name"], agent["kind"], agent["role"], + agent["node_id"], agent["home_node_id"], agent["district"], + agent["is_public"], agent["is_orchestrator"], agent["status"], + agent["id"], agent["id"], agent["display_name"], agent["role"], + agent["district"], True + ) + print(f" ✅ {agent['display_name']}") + + print(f"✅ Seeded {len(CORE_AGENTS_NODE1)} core agents") + + +async def link_agents_to_microdaos(conn: asyncpg.Connection): + """Link orchestrator agents to their MicroDAOs.""" + print("🔗 Linking agents to MicroDAOs...") + + links = [ + ("daarwizz", "dao_daarion", True), # is_core + ("helion", "dao_energy", True), + ("greenfood", "dao_greenfood", True), + ("soul", "dao_soul", True), + ] + + for agent_id, microdao_id, is_core in links: + await conn.execute(""" + INSERT INTO microdao_agents (microdao_id, agent_id, is_core, role) + VALUES ($1, $2, $3, 'orchestrator') + ON CONFLICT (microdao_id, agent_id) DO UPDATE SET + is_core = EXCLUDED.is_core, + role = EXCLUDED.role + """, microdao_id, agent_id, is_core) + print(f" ✅ Linked {agent_id} → {microdao_id}") + + print("✅ Linked agents to MicroDAOs") + + +async def main(): + """Main recovery function.""" + print("=" * 60) + print("🏙️ DAARION City Emergency Recovery") + print("=" * 60) + print() + + conn = None + try: + print(f"🔗 Connecting to database...") + conn = await asyncpg.connect(DATABASE_URL) + print("✅ Connected") + print() + + # Seed in order + await seed_microdaos(conn) + print() + await seed_core_agents(conn) + print() + await link_agents_to_microdaos(conn) + print() + + # Summary + microdao_count = await conn.fetchval("SELECT COUNT(*) FROM microdao") + agent_count = await conn.fetchval("SELECT COUNT(*) FROM agents") + + print("=" * 60) + print("✅ Recovery complete!") + print(f" MicroDAOs: {microdao_count}") + print(f" Agents: {agent_count}") + print() + print("📝 Next steps:") + print(" 1. Run migrations if needed") + print(" 2. Run scripts/sync-node2-dagi-agents.py for NODE2 agents") + print(" 3. Upload logos/banners to MinIO if not already done") + print("=" * 60) + + except Exception as e: + print(f"❌ Error during recovery: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + finally: + if conn: + await conn.close() + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/services/city-service/lib/assets_client.py b/services/city-service/lib/assets_client.py new file mode 100644 index 00000000..4fe525ea --- /dev/null +++ b/services/city-service/lib/assets_client.py @@ -0,0 +1,162 @@ +""" +MinIO client for uploading assets (logos, banners, avatars) to S3-compatible storage. +""" +import os +from datetime import datetime +from uuid import uuid4 +from typing import BinaryIO, Optional +from minio import Minio +from minio.error import S3Error +import logging + +logger = logging.getLogger(__name__) + +# MinIO configuration from environment +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "http://minio:9000") +MINIO_ACCESS_KEY = os.getenv("MINIO_ROOT_USER", "assets-admin") +MINIO_SECRET_KEY = os.getenv("MINIO_ROOT_PASSWORD", "") +ASSETS_BUCKET = os.getenv("ASSETS_BUCKET", "daarion-assets") +ASSETS_PUBLIC_BASE_URL = os.getenv( + "ASSETS_PUBLIC_BASE_URL", + "https://assets.daarion.space/daarion-assets" +) + +# Initialize MinIO client +_minio_client: Optional[Minio] = None + + +def get_minio_client() -> Minio: + """Get or create MinIO client instance.""" + global _minio_client + if _minio_client is None: + # Remove http:// or https:// from endpoint for MinIO client + endpoint = MINIO_ENDPOINT.replace("http://", "").replace("https://", "") + secure = MINIO_ENDPOINT.startswith("https://") + + _minio_client = Minio( + endpoint, + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + secure=secure, + ) + return _minio_client + + +def ensure_bucket(): + """Ensure the assets bucket exists, create if it doesn't.""" + try: + client = get_minio_client() + if not client.bucket_exists(ASSETS_BUCKET): + client.make_bucket(ASSETS_BUCKET) + logger.info(f"Created bucket: {ASSETS_BUCKET}") + else: + logger.debug(f"Bucket {ASSETS_BUCKET} already exists") + except S3Error as e: + logger.error(f"Failed to ensure bucket {ASSETS_BUCKET}: {e}") + raise + + +def upload_asset( + file_obj: BinaryIO, + content_type: str, + prefix: str, + filename: Optional[str] = None +) -> str: + """ + Upload a file to MinIO and return the public URL. + + Args: + file_obj: File-like object to upload + content_type: MIME type (e.g., 'image/png', 'image/jpeg') + prefix: Path prefix (e.g., 'microdao/logo', 'agents/avatar') + filename: Optional original filename (for extension detection) + + Returns: + Full public HTTPS URL to the uploaded asset + """ + try: + ensure_bucket() + client = get_minio_client() + + # Generate object key: prefix/YYYY/MM/DD/uuid.ext + date_path = datetime.utcnow().strftime("%Y/%m/%d") + unique_id = uuid4().hex + + # Try to extract extension from filename or content_type + ext = "" + if filename: + ext = os.path.splitext(filename)[1] or "" + elif content_type: + # Map common content types to extensions + ext_map = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/webp": ".webp", + "image/gif": ".gif", + } + ext = ext_map.get(content_type, "") + + object_key = f"{prefix}/{date_path}/{unique_id}{ext}" + + # Get file size (seek to end, get position, reset) + file_obj.seek(0, 2) # Seek to end + file_size = file_obj.tell() + file_obj.seek(0) # Reset to beginning + + # Upload to MinIO + client.put_object( + ASSETS_BUCKET, + object_key, + file_obj, + length=file_size, + content_type=content_type, + ) + + # Return public URL + public_url = f"{ASSETS_PUBLIC_BASE_URL}/{object_key}" + logger.info(f"Uploaded asset to {public_url}") + + return public_url + + except S3Error as e: + logger.error(f"Failed to upload asset: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error uploading asset: {e}") + raise + + +def delete_asset(object_key: str) -> bool: + """ + Delete an asset from MinIO. + + Args: + object_key: Full object key or URL (will extract key from URL if needed) + + Returns: + True if deleted, False otherwise + """ + try: + client = get_minio_client() + + # If it's a full URL, extract the object key + if object_key.startswith("http://") or object_key.startswith("https://"): + # Extract key from URL like: https://assets.daarion.space/daarion-assets/microdao/logo/... + parts = object_key.split(f"{ASSETS_PUBLIC_BASE_URL}/") + if len(parts) < 2: + logger.warning(f"Could not extract object key from URL: {object_key}") + return False + object_key = parts[1] + + client.remove_object(ASSETS_BUCKET, object_key) + logger.info(f"Deleted asset: {object_key}") + return True + + except S3Error as e: + logger.error(f"Failed to delete asset {object_key}: {e}") + return False + except Exception as e: + logger.error(f"Unexpected error deleting asset: {e}") + return False + diff --git a/services/city-service/requirements.txt b/services/city-service/requirements.txt index f6f8f8b7..c3191c97 100644 --- a/services/city-service/requirements.txt +++ b/services/city-service/requirements.txt @@ -9,3 +9,4 @@ httpx==0.26.0 nats-py==2.6.0 Pillow==10.2.0 python-multipart==0.0.9 +minio==7.2.0 diff --git a/services/city-service/routes_city.py b/services/city-service/routes_city.py index 7feb489b..fff0cdcf 100644 --- a/services/city-service/routes_city.py +++ b/services/city-service/routes_city.py @@ -14,6 +14,14 @@ import uuid from PIL import Image import shutil +# MinIO assets client +try: + from lib.assets_client import upload_asset as minio_upload_asset + MINIO_AVAILABLE = True +except ImportError: + MINIO_AVAILABLE = False + logger.warning("MinIO client not available, falling back to local storage") + from models_city import ( CityRoomRead, CityRoomCreate, @@ -339,24 +347,69 @@ async def upload_asset( processed_bytes, thumb_bytes = process_image(content, max_size=max_size, force_square=force_square) - # Save to disk + # Map type to prefix + type_to_prefix = { + 'microdao_logo': 'microdao/logo', + 'microdao_banner': 'microdao/banner', + 'room_logo': 'rooms/logo', + 'room_banner': 'rooms/banner', + 'agent_avatar': 'agents/avatar', + } + prefix = type_to_prefix.get(type, 'uploads') + + # Upload to MinIO if available, otherwise fallback to local storage + if MINIO_AVAILABLE: + try: + # Upload processed image + processed_file = io.BytesIO(processed_bytes) + processed_url = minio_upload_asset( + processed_file, + content_type="image/png", + prefix=prefix, + filename=file.filename + ) + + # Upload thumbnail if exists + thumb_url = None + if thumb_bytes: + thumb_file = io.BytesIO(thumb_bytes) + thumb_url = minio_upload_asset( + thumb_file, + content_type="image/png", + prefix=f"{prefix}/thumb", + filename=file.filename + ) + + return { + "original_url": processed_url, + "processed_url": processed_url, + "thumb_url": thumb_url or processed_url + } + except Exception as e: + logger.warning(f"MinIO upload failed, falling back to local: {e}") + # Fall through to local storage + + # Fallback: Save to local disk filename = f"{uuid.uuid4()}.png" filepath = f"static/uploads/{filename}" thumb_filepath = f"static/uploads/thumb_{filename}" + os.makedirs("static/uploads", exist_ok=True) + with open(filepath, "wb") as f: f.write(processed_bytes) - with open(thumb_filepath, "wb") as f: - f.write(thumb_bytes) - + if thumb_bytes: + with open(thumb_filepath, "wb") as f: + f.write(thumb_bytes) + # Construct URLs - base_url = "/static/uploads" + base_url = "/api/static/uploads" return { "original_url": f"{base_url}/{filename}", "processed_url": f"{base_url}/{filename}", - "thumb_url": f"{base_url}/thumb_{filename}" + "thumb_url": f"{base_url}/thumb_{filename}" if thumb_bytes else f"{base_url}/{filename}" } except Exception as e: