Files
aramesh-backend/scripts/migrate-media.py
T
MMD ce0735d775 feat(stack): full overhaul — hardening, ops tooling, monitoring, analytics, chatbot
Security: deep /api/health (DB+MinIO probes, 503 on failure) + /api/live;
rate-limited public forms with honeypots; real client IP behind Traefik
(--proxy-headers); TipTap URL sanitization on write (nh3); non-root
containers + slim .dockerignore; prod MinIO console bound to 127.0.0.1;
HSTS/nosniff on TLS routers; JWT 8h; no default credentials in code.

Reliability: Alembic migrations (create_all baseline); graceful shutdown
(exec uvicorn); hardened backups (checksums, sanity check, freshness gauge,
optional rclone off-site) + restore.sh + hourly db-backup sidecar (ops
profile); stack.sh ops tooling (deploy/logs/health/backup/restore/monitoring);
.gitattributes LF for shell scripts.

Orchestration: json-file log rotation, pinned images, per-service memory
limits, edge/internal networks, Traefik ping/accesslog/metrics + flood
limiter, conditional minio-setup, pgvector db image, one-shot db-init
(umami + aramesh_chatbot databases).

Chatbot: unified from site-chatbot branch (RAG + pgvector, SSE, ChatWidget).

Monitoring: Prometheus/Alertmanager/Loki/Promtail/Grafana + cAdvisor,
node/postgres/blackbox exporters, provisioned dashboards and 10 alert
rules; alerts delivered to Bale (tapi.bale.ai — Telegram-compatible).

Analytics: Umami on shared Postgres, build-time tracker injection, custom
events (contact/newsletter/phone/chat/lang) via frontend/src/analytics.ts.

Hygiene: CSV export for contact messages and subscribers; upload image
optimization (Pillow); CI script + GitHub-compatible workflow; audit.sh;
gen-env.sh for strong credentials.
2026-09-19 00:24:57 +03:30

190 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""انتقال تصاویرِ CDN خارجی (مثل i.postimg.cc) به MinIO و اصلاح مسیر در دیتابیس.
مشکل: لوگو، هیرو، گالری و پس‌زمینه صفحات همگی به i.postimg.cc (در دسترس نیست از
این ماشین) یا cdn.housedesign.ai اشاره می‌کنند — مثل فونت، هر بار از اینترنت دانلود می‌شوند.
راه‌حل: این اسکریپت (روی ماشینی که به اینترنتِ آن CDN در دسترس است) هر تصویر را
یک‌بار می‌گیرد، در مین‌آیو آپلود می‌کند و مقدارِ فیلد دیتابیس را به /media/... تغییر
می‌دهد. بار اجرا می‌کنید فقط تصویرهایی منتقل می‌شوند که هنوز مسیرشان /media نیست.
اجرا (از بک‌اند — نیاز به python-dotenv برای خواندن .env ندارد؛ تنظیمات از متغیر محیط):
cd backend
pip install requests
export VITE_API_URL=http://localhost:5173 # یا آدرس واقعی
python scripts/migrate-media.py
python scripts/migrate-media.py --force # حتی مواردِ /media شده را هم دوباره بررسی کن
خروجی:
✓ 4 تصویر منتقل شد. (هر ردیف تازه‌ثبت شده)
✗ 12 تصویر هنوز در منابع منبع نرسید (دانلود ناموفق) — گزارش در انتها.
"""
import os
import re
import sys
import uuid
from collections import defaultdict
from io import BytesIO
# اگر .env ریشه هست، متغیرهایش (ADMIN_PASSWORD و ...) خوانده می‌شوند
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_ENV_FILE = os.path.join(_REPO_ROOT, ".env")
if os.path.exists(_ENV_FILE):
with open(_ENV_FILE, encoding="utf-8") as _f:
for _line in _f:
_line = _line.strip()
if _line and not _line.startswith("#") and "=" in _line:
_k, _, _v = _line.partition("=")
os.environ.setdefault(_k.strip(), _v.strip())
import requests
API = os.environ.get("VITE_API_URL", "http://localhost:5173")
USER = os.environ.get("ADMIN_USERNAME", "admin")
# رمز پیش‌فرض ندارد — از .env یا متغیر محیطی خوانده می‌شود؛ نبودش یعنی توقف با خطا
PASS = os.environ["ADMIN_PASSWORD"]
FORCE = "--force" in sys.argv
print(f"API بک‌اند: {API}")
def login() -> str:
r = requests.post(f"{API}/api/auth/login", json={"username": USER, "password": PASS}, timeout=20)
r.raise_for_status()
return r.json()["access_token"]
def snapshot(token: str) -> dict:
r = requests.get(f"{API}/api/site", headers={"Authorization": f"Bearer {token}"}, timeout=20)
r.raise_for_status()
return r.json()
def upload(token: str, content: bytes, ext: str) -> str:
"""آپلود بایت‌ها با فرمتِ دیدِ هدر (بک‌اند فقط این‌ها را می‌پذیرد)."""
mime = {
"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
"webp": "image/webp", "gif": "image/gif", "avif": "image/avif",
}
ext = ext if ext in mime else "png"
filename = f"{uuid.uuid4().hex}.{ext}"
r = requests.post(
f"{API}/api/admin/upload",
headers={"Authorization": f"Bearer {token}"},
files={"file": (filename, BytesIO(content), mime[ext])},
timeout=90,
)
r.raise_for_status()
return r.json()["url"] # e.g. /media/2026/09/<hash>.png
def patch(token: str, resource: str, item_id: int, field: str, value: str) -> None:
r = requests.patch(
f"{API}/api/admin/{resource}/{item_id}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={field: value},
timeout=20,
)
r.raise_for_status()
def sniff(prefix: bytes) -> str:
"""فرمت واقعی از هدر بایت — تا HTML/خطا به دیتابیس نرود و فرمت درست آپلود شود."""
if prefix.startswith(b"\xff\xd8\xff"):
return "jpg"
if prefix.startswith(b"\x89PNG"):
return "png"
if prefix.startswith(b"GIF8"):
return "gif"
if prefix.startswith(b"RIFF") and b"WEBP" in prefix[:12]:
return "webp"
if prefix.startswith(b"\x00\x00\x00") and b"ftyp" in prefix[:16]:
return "avif"
return ""
def pick_bytes(url: str) -> tuple[bytes | None, str]:
"""دانلود؛ فقط تصویرِ واقعی → (بایت، فرمت). غیرتصویر/خطا → (None, '')."""
try:
r = requests.get(url, timeout=45, allow_redirects=True)
if r.status_code != 200:
return None, ""
body = r.content
ext = sniff(body[:16])
if not ext:
return None, "" # HTML یا خطا — نه تصویر
return body, ext
except requests.RequestException:
return None, ""
# ردیف‌ها: (resource, item_id, field, current_url) — به‌دست از /api/site
def collect(site: dict) -> list[tuple[str, int, str, str]]:
rows: list[tuple[str, int, str, str]] = []
b = site.get("brand") or {}
if b.get("id"):
for col in ("logo", "hero", "contact_bg"):
if col in b and b[col].startswith("http"):
rows.append(("brand", b["id"], col, b[col]))
for g in site.get("gallery") or []:
if g.get("src", "").startswith("http"):
rows.append(("gallery", g["id"], "src", g["src"]))
for s in site.get("section_headings") or []:
if s.get("image", "").startswith("http"):
rows.append(("section-headings", s["id"], "image", s["image"]))
for p in site.get("page_heroes") or []:
if p.get("image", "").startswith("http"):
rows.append(("page-heroes", p["id"], "image", p["image"]))
ba = site.get("before_after") or {}
if ba.get("id"):
for col in ("before", "after"):
if ba[col].startswith("http"):
rows.append(("before-after", ba["id"], col, ba[col]))
return rows
def main() -> None:
token = login()
site = snapshot(token)
rows = collect(site)
# collect فقط URLهایی را برمی‌گرداند که با http شروع می‌شوند (نسبی /media را نادیده می‌گیرد)
if not rows:
print("هیچ URL خارجی برای انتقال نیست — همه در MinIO هستند.")
return
ok, failed = [], []
for resource, item_id, field, url in rows:
print(f" {resource}:{item_id}.{field}\n <- {url}")
data, ext = pick_bytes(url)
if data is None:
failed.append((resource, item_id, field, url))
print(" ✗ دانلود ناموفق (منبع پاسخگو نبود یا HTML/خطا داد)")
continue
new_url = upload(token, data, ext)
print(f" ✓ -> {new_url}")
try:
patch(token, resource, item_id, field, new_url)
except requests.RequestException as e:
# آپلود شد ولی DB نگرفت — سخت: رجوع بعدی ماندنی
print(f" ! آپلود شد ولی دیتابیس آپدیت نشد: {e}")
failed.append((resource, item_id, field, url))
continue
ok.append((resource, item_id, field, new_url))
print(f"\n===== خلاصه: {len(ok)} تصویر منتقل شد =====")
if failed:
print("هنوز در دسترس نیست (بعداً دوباره اجرا کنید):")
for resource, item_id, field, url in failed:
print(f" {url} -> {resource}:{item_id}.{field}")
if __name__ == "__main__":
main()