feat(db-hardening): Add database persistence, backups, and MinIO assets storage
Database Hardening: - Add docker-compose.db.yml with persistent PostgreSQL volume - Add automatic DB backups every 12h (7 days, 4 weeks, 6 months retention) - Add MinIO S3-compatible storage for assets Assets Migration: - Add MinIO client (lib/assets_client.py) for upload/delete - Update upload endpoint to use MinIO (with local fallback) - Add migration 043_asset_urls_to_text.sql for full HTTPS URLs - Simplify normalizeAssetUrl for S3 URLs Recovery: - Add seed_full_city_reset.py for emergency city recovery - Add DB_RESTORE.md with backup restore instructions - Add SEED_RECOVERY.md with recovery procedures - Add INFRA_ASSETS_MINIO.md with MinIO setup guide Task: TASK_PHASE_DATABASE_HARDENING_AND_ASSETS_MIGRATION_v1
This commit is contained in:
@@ -1,13 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Normalize asset URL for display.
|
* Normalize asset URL for display.
|
||||||
* This is the SINGLE source of truth for building static asset URLs.
|
|
||||||
*
|
*
|
||||||
* Handles various URL formats from the backend:
|
* After migration to MinIO/S3, most assets will be full HTTPS URLs.
|
||||||
* - /api/static/uploads/... - already correct, return as-is
|
* This function handles both new S3 URLs and legacy local file paths.
|
||||||
* - /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
|
|
||||||
*
|
*
|
||||||
* IMPORTANT: Do NOT manually concatenate /api/static anywhere in the codebase.
|
* IMPORTANT: Do NOT manually concatenate /api/static anywhere in the codebase.
|
||||||
* Always use this function instead.
|
* Always use this function instead.
|
||||||
@@ -15,45 +10,33 @@
|
|||||||
export function normalizeAssetUrl(url: string | null | undefined): string | null {
|
export function normalizeAssetUrl(url: string | null | undefined): string | null {
|
||||||
if (!url) return 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://')) {
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already correct format with /api/static
|
// Legacy: Already correct format with /api/static
|
||||||
if (url.startsWith('/api/static')) {
|
if (url.startsWith('/api/static')) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static assets in public folder (/assets/...)
|
// Legacy: Static assets in public folder (/assets/...)
|
||||||
if (url.startsWith('/assets/')) {
|
if (url.startsWith('/assets/')) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Old format with /static/ prefix - add /api
|
// Legacy: Old format with /static/ prefix - add /api
|
||||||
if (url.startsWith('/static/')) {
|
if (url.startsWith('/static/')) {
|
||||||
return `/api${url}`;
|
return `/api${url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relative path without leading slash (uploads/..., static/...)
|
// Legacy: Relative paths - normalize to /api/static/
|
||||||
// Remove any duplicate prefixes and normalize
|
let cleaned = url.replace(/^\/+/, ''); // Remove leading slashes
|
||||||
let cleaned = url;
|
if (cleaned && !cleaned.startsWith('http')) {
|
||||||
|
|
||||||
// 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('/')) {
|
|
||||||
return `/api/static/${cleaned}`;
|
return `/api/static/${cleaned}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown format - return as-is (might be a simple filename)
|
// Unknown format - return as-is
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
83
docker-compose.db.yml
Normal file
83
docker-compose.db.yml
Normal file
@@ -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
|
||||||
|
|
||||||
138
docs/DB_RESTORE.md
Normal file
138
docs/DB_RESTORE.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
389
docs/INFRA_ASSETS_MINIO.md
Normal file
389
docs/INFRA_ASSETS_MINIO.md
Normal file
@@ -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 <password>
|
||||||
|
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 працює
|
||||||
|
|
||||||
230
docs/SEED_RECOVERY.md
Normal file
230
docs/SEED_RECOVERY.md
Normal file
@@ -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 <password>
|
||||||
|
|
||||||
|
# Завантажити файли
|
||||||
|
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;"
|
||||||
|
```
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
33
migrations/043_asset_urls_to_text.sql
Normal file
33
migrations/043_asset_urls_to_text.sql
Normal file
@@ -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 $$;
|
||||||
|
|
||||||
290
scripts/seed_full_city_reset.py
Normal file
290
scripts/seed_full_city_reset.py
Normal file
@@ -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())
|
||||||
|
|
||||||
162
services/city-service/lib/assets_client.py
Normal file
162
services/city-service/lib/assets_client.py
Normal file
@@ -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
|
||||||
|
|
||||||
@@ -9,3 +9,4 @@ httpx==0.26.0
|
|||||||
nats-py==2.6.0
|
nats-py==2.6.0
|
||||||
Pillow==10.2.0
|
Pillow==10.2.0
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
|
minio==7.2.0
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ import uuid
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
import shutil
|
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 (
|
from models_city import (
|
||||||
CityRoomRead,
|
CityRoomRead,
|
||||||
CityRoomCreate,
|
CityRoomCreate,
|
||||||
@@ -339,24 +347,69 @@ async def upload_asset(
|
|||||||
|
|
||||||
processed_bytes, thumb_bytes = process_image(content, max_size=max_size, force_square=force_square)
|
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"
|
filename = f"{uuid.uuid4()}.png"
|
||||||
filepath = f"static/uploads/{filename}"
|
filepath = f"static/uploads/{filename}"
|
||||||
thumb_filepath = f"static/uploads/thumb_{filename}"
|
thumb_filepath = f"static/uploads/thumb_{filename}"
|
||||||
|
|
||||||
|
os.makedirs("static/uploads", exist_ok=True)
|
||||||
|
|
||||||
with open(filepath, "wb") as f:
|
with open(filepath, "wb") as f:
|
||||||
f.write(processed_bytes)
|
f.write(processed_bytes)
|
||||||
|
|
||||||
with open(thumb_filepath, "wb") as f:
|
if thumb_bytes:
|
||||||
f.write(thumb_bytes)
|
with open(thumb_filepath, "wb") as f:
|
||||||
|
f.write(thumb_bytes)
|
||||||
|
|
||||||
# Construct URLs
|
# Construct URLs
|
||||||
base_url = "/static/uploads"
|
base_url = "/api/static/uploads"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"original_url": f"{base_url}/{filename}",
|
"original_url": f"{base_url}/{filename}",
|
||||||
"processed_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:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user