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.
This commit is contained in:
MMD
2026-09-19 00:24:57 +03:30
parent 805f4594d4
commit ce0735d775
75 changed files with 4287 additions and 153 deletions
+79 -1
View File
@@ -1,10 +1,13 @@
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
import csv
import io
import uuid
from datetime import datetime, timezone
import boto3
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status
from PIL import Image, ImageOps
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -263,6 +266,33 @@ ALLOWED_IMAGE_TYPES = {
"image/avif": "avif",
}
# بهینه‌سازی در زمان آپلود — ابعاد بزرگ و فشرده‌سازی ضعیف، اصلی‌ترین عامل کندی سایت است
MAX_DIMENSION = 2000
REENCODE_TYPES = {"image/jpeg", "image/png", "image/webp"}
def _optimize_image(data: bytes, content_type: str) -> tuple[bytes, str, int, int]:
"""تصویر را به حداکثر ۲۰۰۰ پیکسل محدود و دوباره فشرده می‌کند؛
اگر خروجی بزرگ‌تر از اصلی شد، همان اصلی برگردد. (gif/avif دست‌نخورده)"""
img = Image.open(io.BytesIO(data))
img = ImageOps.exif_transpose(img)
orig_w, orig_h = img.size
if max(img.size) > MAX_DIMENSION:
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
buf = io.BytesIO()
if content_type == "image/jpeg":
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
img.save(buf, "JPEG", quality=82, optimize=True, progressive=True)
elif content_type == "image/png":
img.save(buf, "PNG", optimize=True)
else:
img.save(buf, "WEBP", quality=82, method=4)
optimized = buf.getvalue()
if len(optimized) < len(data):
return optimized, content_type, *img.size
return data, content_type, orig_w, orig_h
@router.post("/upload")
async def upload_media(
@@ -287,6 +317,14 @@ async def upload_media(
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
)
width = height = None
if content_type in REENCODE_TYPES:
try:
data, content_type, width, height = _optimize_image(data, content_type)
except Exception:
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره می‌شود
pass
now = datetime.now(timezone.utc)
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
@@ -307,9 +345,49 @@ async def upload_media(
"key": key,
"size": len(data),
"content_type": content_type,
"width": width,
"height": height,
}
# ---------- خروجی CSV (برای تیم فروش) ----------
def _csv_response(filename: str, header: list[str], rows: list[list]) -> Response:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(header)
writer.writerows(rows)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
return Response(
# BOM ابتدای فایل — تا Excel متن فارسی را درست باز کند
content="\ufeff" + buf.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}-{stamp}.csv"'},
)
@router.get("/messages/export.csv")
def export_messages(db: Session = Depends(get_db)):
rows = [
[m.id, m.name, m.phone, m.note, "yes" if m.is_read else "no", m.created_at.isoformat()]
for m in db.query(models.ContactMessage).order_by(models.ContactMessage.created_at.desc()).all()
]
return _csv_response(
"contact-messages", ["id", "name", "phone", "note", "is_read", "created_at"], rows
)
@router.get("/subscribers/export.csv")
def export_subscribers(db: Session = Depends(get_db)):
rows = [
[s.id, s.email, "yes" if s.is_active else "no", s.created_at.isoformat()]
for s in db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all()
]
return _csv_response(
"newsletter-subscribers", ["id", "email", "is_active", "created_at"], rows
)
# پیام‌های تماس
@router.get("/messages", response_model=list[ContactMessageOut])
def list_messages(unread_only: bool = False, db: Session = Depends(get_db)):
+40 -5
View File
@@ -1,17 +1,48 @@
"""انتهای فرم‌ها — ثبت پیام تماس و عضویت در خبرنامه."""
"""انتهای فرم‌ها — ثبت پیام تماس و عضویت در خبرنامه.
from fastapi import APIRouter, Depends
هر فرم یک دامنهٔ پنهان (honeypot) دارد: ربات‌ها همهٔ فیلدها را پُر می‌کنند؛
در آن حالت پاسخ موفقِ قلابی برمی‌گردد و چیزی ذخیره نمی‌شود.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from ..database import get_db
from .. import models
from ..database import get_db
from ..rate_limit import contact_limiter, newsletter_limiter
from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut
router = APIRouter()
def _fake_contact(payload: ContactIn) -> ContactMessageOut:
"""پاسخ موفق بدون ذخیره — تا ربات متوجه تله نشود."""
return ContactMessageOut(
id=0,
name=payload.name.strip(),
phone=payload.phone.strip(),
note="",
is_read=True,
created_at=datetime.now(timezone.utc),
)
def _fake_subscriber(payload: NewsletterIn) -> SubscriberOut:
return SubscriberOut(
id=0,
email=str(payload.email).lower(),
is_active=True,
created_at=datetime.now(timezone.utc),
)
@router.post("/contact", response_model=ContactMessageOut, status_code=201)
def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
def submit_contact(payload: ContactIn, request: Request, db: Session = Depends(get_db)):
contact_limiter.hit(request)
if payload.website:
return _fake_contact(payload)
message = models.ContactMessage(
name=payload.name.strip(),
phone=payload.phone.strip(),
@@ -24,7 +55,11 @@ def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
@router.post("/newsletter", response_model=SubscriberOut, status_code=201)
def subscribe(payload: NewsletterIn, db: Session = Depends(get_db)):
def subscribe(payload: NewsletterIn, request: Request, db: Session = Depends(get_db)):
newsletter_limiter.hit(request)
if payload.website:
return _fake_subscriber(payload)
email = str(payload.email).lower()
subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first()
+42 -2
View File
@@ -1,8 +1,13 @@
"""انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت."""
import boto3
from botocore.config import Config as BotoConfig
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..config import settings
from ..database import get_db
from .. import models
from ..schemas import (
@@ -22,9 +27,44 @@ from ..schemas import (
router = APIRouter()
@router.get("/live")
def live():
"""زنده‌بودن پروسه — بدون وابستگی؛ برای restart gate استفاده نمی‌شود."""
return {"status": "ok"}
@router.get("/health")
def health():
return {"status": "ok", "app": "Aramesh Building Group API"}
def health(db: Session = Depends(get_db)):
"""بررسی عمق سلامت — اتصال DB و MinIO واقعاً آزموده می‌شود؛
در صورت خرابی هر وابستگی، 503 با جزئیات برمی‌گردد."""
checks: dict[str, str] = {}
try:
db.execute(text("SELECT 1"))
checks["database"] = "ok"
except Exception:
checks["database"] = "error"
try:
s3 = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name="us-east-1",
config=BotoConfig(connect_timeout=2, read_timeout=2, retries={"max_attempts": 1}),
)
s3.head_bucket(Bucket=settings.s3_bucket)
checks["storage"] = "ok"
except Exception:
checks["storage"] = "error"
if any(state != "ok" for state in checks.values()):
return JSONResponse(
status_code=503,
content={"status": "degraded", "checks": checks},
)
return {"status": "ok", "app": "Aramesh Building Group API", "checks": checks}
@router.get("/brand", response_model=BrandOut)
+2 -1
View File
@@ -13,7 +13,8 @@ class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api"
secret_key: str = "dev-secret-change-me-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 1440
# ۸ ساعت — توکن stateless است و امکان ابطال ندارد؛ عمر کوتاه‌تر ریسک نشت را کم می‌کند
access_token_expire_minutes: int = 480
cors_origins: list[str] = ["*"]
admin_username: str = "admin"
admin_password: str = "aramesh123"
+11
View File
@@ -0,0 +1,11 @@
"""ساخت جداول پایه (baseline) — پیش از مهاجرت‌های Alembic.
روی دیتابیس تازه، همهٔ جدول‌ها از مدل‌های فعلی ساخته می‌شوند؛ روی دیتابیس موجود
بی‌اثر است (create_all جدول‌های موجود را دست نمی‌زند). از این‌جا به بعد تغییرات
اسکیما فقط با revisionهای Alembic اعمال می‌شوند.
"""
from . import models # noqa: F401 — ثبت مدل‌ها
from .database import Base, engine
Base.metadata.create_all(bind=engine)
+12 -2
View File
@@ -1,15 +1,17 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت جداول
from .api import admin, forms, public
from .config import settings
from .database import Base, engine
from .migrations import run_all as run_migrations
from .seo_pages import router as seo_router
# ساخت جداول پایه — تغییرات بعدی اسکیما فقط با revisionهای Alembic اعمال می‌شوند
# (مigrations/versions). اجرای اینجا برای اجرای مستقیم uvicorn در محیط توسعه است؛
# در کانتینر، CMD همین کار را قبل از alembic upgrade head انجام می‌دهد.
Base.metadata.create_all(bind=engine)
run_migrations()
app = FastAPI(
title="Aramesh Building Group API",
@@ -29,6 +31,8 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
# پاسخ‌های JSON /api — فشرده‌سازی وقتی بدنه قابل‌توجه است
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.include_router(public.router, prefix="/api", tags=["عمومی"])
app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"])
@@ -37,6 +41,12 @@ app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
app.include_router(seo_router, tags=["سئو"])
# متریک‌های Prometheus — فقط Prometheus در شبکه داخلی اسکرپ می‌کند
# (Traefik مسیر /metrics را نمی‌شناسد، پس از بیرون در دسترس نیست)
from prometheus_fastapi_instrumentator import Instrumentator # noqa: E402
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
@app.get("/")
def root():
+66 -15
View File
@@ -1,38 +1,74 @@
"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login.
"""محدودسازی نرخ درخواست — پنجرهٔ لغزان درون‌حافظه‌ای.
پیش‌فرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک می‌کند.
شمارنده در حافظه همین پروسه است — با ری‌استارت بک‌اند پاک می‌شود و همین کافی است.
برای ورود ادمین (بر پایه تلاش ناموفق) و فرم‌های عمومی (بر پایه هر ارسال)
استفاده می‌شود. شمارنده‌ها در حافظهٔ همین پروسه‌اند — با ری‌استارت پاک می‌شوند؛
به همین دلیل uvicorn تک‌کارگر نگه داشته شده است.
"""
import time
from fastapi import HTTPException, Request, status
MAX_FAILED_ATTEMPTS = 5
WINDOW_SECONDS = 15 * 60
_failed: dict[str, list[float]] = {}
def client_ip(request: Request) -> str:
"""IP واقعی کلاینت — پشت Traefik از X-Forwarded-For خوانده می‌شود
(uvicorn با --proxy-headers هم client.host را درست می‌کند)."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _prune(key: str, now: float) -> list[float]:
recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS]
_failed[key] = recent
return recent
class RateLimiter:
"""پنجرهٔ لغزان بر پایه کلید (عملاً IP) — رویدادها پس از پنجره منقضی می‌شوند."""
def __init__(self, max_events: int, window_seconds: int, message: str):
self.max_events = max_events
self.window_seconds = window_seconds
self.message = message
self._events: dict[str, list[float]] = {}
def _recent(self, key: str, now: float) -> list[float]:
recent = [t for t in self._events.get(key, []) if now - t < self.window_seconds]
self._events[key] = recent
return recent
def _reject(self, recent: list[float]) -> None:
now = time.monotonic()
retry_after = max(1, int(self.window_seconds - (now - recent[0])))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=self.message,
headers={"Retry-After": str(retry_after)},
)
def hit(self, request: Request) -> None:
"""یک رویداد ثبت می‌کند؛ اگر سهمیه کلید پر باشد 429 برمی‌گرداند."""
key = client_ip(request)
now = time.monotonic()
recent = self._recent(key, now)
if len(recent) >= self.max_events:
self._reject(recent)
self._events.setdefault(key, []).append(now)
def clear(self, request: Request) -> None:
self._events.pop(client_ip(request), None)
# ورود ادمین: ۵ تلاش ناموفق در ۱۵ دقیقه — فقط شکست‌ها شمرده می‌شوند
_failed: dict[str, list[float]] = {}
LOGIN_MAX_FAILED_ATTEMPTS = 5
LOGIN_WINDOW_SECONDS = 15 * 60
def ensure_allowed(request: Request) -> None:
"""اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد می‌شود."""
key = client_ip(request)
recent = _prune(key, time.monotonic())
if len(recent) >= MAX_FAILED_ATTEMPTS:
retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0])))
now = time.monotonic()
recent = [t for t in _failed.get(key, []) if now - t < LOGIN_WINDOW_SECONDS]
_failed[key] = recent
if len(recent) >= LOGIN_MAX_FAILED_ATTEMPTS:
retry_after = max(1, int(LOGIN_WINDOW_SECONDS - (now - recent[0])))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="تلاش‌های ناموفق زیاد است — بعداً دوباره امتحان کنید",
@@ -47,3 +83,18 @@ def record_failure(request: Request) -> None:
def clear(request: Request) -> None:
"""با ورود موفق، تاریخچه تلاش‌های ناموفق IP پاک می‌شود."""
_failed.pop(client_ip(request), None)
# فرم تماس: ۵ ارسال در ۱۵ دقیقه برای هر IP
contact_limiter = RateLimiter(
max_events=5,
window_seconds=15 * 60,
message="تعداد پیام‌های ارسالی زیاد است — کمی بعد دوباره امتحان کنید",
)
# خبرنامه: ۳ عضویت در ساعت برای هر IP
newsletter_limiter = RateLimiter(
max_events=3,
window_seconds=60 * 60,
message="تعداد درخواست‌ها زیاد است — کمی بعد دوباره امتحان کنید",
)
+58
View File
@@ -0,0 +1,58 @@
"""پاک‌سازی ورودی‌های غنی در زمان ذخیره.
بدنه مقاله‌ها سند TipTap (ProseMirror) است و فرانت‌اند همان را با
renderToHTMLString به HTML تبدیل و مستقیم تزریق می‌کند؛ متن گره‌ها در آن
مسیر escape می‌شود، اما آدرس‌های href/src عیناً وارد HTML می‌شوند — پس
اسکیم‌های خطرناک (javascript: و data: و ...) باید در سمت سرور حذف شوند.
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
# اسکیم‌های مجاز برای href/src — مسیرهای نسبی و لنگر هم بی‌اسکیم‌اند و می‌گذرند
SAFE_URL_SCHEMES = {"http", "https", "mailto", "tel"}
URL_ATTRS = ("href", "src")
def _clean_url(value: Any) -> str | None:
"""آدرس امن را برمی‌گرداند؛ در غیر این صورت None (مشخصه حذف می‌شود)."""
if not isinstance(value, str):
return value if value is not None else None
stripped = value.strip().replace("\n", "").replace("\r", "").replace("\t", "")
if not stripped:
return None
parsed = urlparse(stripped)
if parsed.scheme and parsed.scheme.lower() not in SAFE_URL_SCHEMES:
return None
return stripped
def _walk(node: Any) -> Any:
if isinstance(node, list):
return [_walk(item) for item in node]
if not isinstance(node, dict):
return node
cleaned: dict[str, Any] = {}
for key, value in node.items():
if key == "attrs" and isinstance(value, dict):
attrs = dict(value)
for attr in URL_ATTRS:
if attr in attrs:
cleaned_url = _clean_url(attrs[attr])
if cleaned_url is None:
attrs.pop(attr, None)
else:
attrs[attr] = cleaned_url
cleaned[key] = attrs
else:
cleaned[key] = _walk(value)
return cleaned
def sanitize_doc(doc: Any) -> Any:
"""سند TipTap را درجا پاک می‌کند (سایر ساختارها دست‌نخورده برمی‌گردند)."""
if not isinstance(doc, dict):
return doc
return _walk(doc)
+9 -2
View File
@@ -3,6 +3,8 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
from .sanitize import sanitize_doc
# ---------- پایه ----------
@@ -386,10 +388,14 @@ class ContactIn(BaseModel):
name: str = Field(min_length=3, max_length=128)
phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$")
note: str = Field(default="", max_length=2000)
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمی‌کند
website: str = Field(default="", max_length=128)
class NewsletterIn(BaseModel):
email: EmailStr
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمی‌کند
website: str = Field(default="", max_length=128)
# ---------- ورودی‌های پنل مدیریت ----------
@@ -460,12 +466,13 @@ EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
def validate_doc_body(value: object) -> dict:
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد."""
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد و
آدرس‌های href/src آن به اسکیم‌های امن محدود شوند."""
if not value:
return dict(EMPTY_DOC)
if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list):
raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}")
return value
return sanitize_doc(value)
class ArticleIn(BaseModel):