زیرساخت: رمزهای اجباری از .env + لایه تولید با TLS + healthcheck و مسیر سئوی Traefik + پشتیبان‌گیری

- docker-compose.yml همه مقادیر محرمانه را با ${VAR:?} از .env می‌خواند — بدون .env استک بالا نمی‌آید
- docker-compose.prod.yml: ورودی 443 + ریدایرکت HTTP به HTTPS + گواهی Let's Encrypt و قواعد Host برای همه روترها
- healthcheck بک‌اند (api/health) و فرانت (wget) + سوار کردن frontend/dist در بک‌اند برای تزریق متا + روتر سئو (robots/sitemap/blog/projects) با اولویت 50
- سرویس mc با پروفایل tools + scripts/backup.sh (pg_dump + آینه MinIO با نگهداری ۱۴ روزه)
- .env.example ریشه + بروزرسانی READMEها
This commit is contained in:
MMD
2026-09-07 04:26:18 +03:30
parent b31124e1e4
commit 2ad3ad7367
6 changed files with 237 additions and 22 deletions
+23
View File
@@ -0,0 +1,23 @@
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
# docker compose به‌طور خودکار این فایل را می‌خواند؛ بدون آن استک بالا نمی‌آید.
# --- رمزهای اصلی (اجباری) ---
# تولید کلید تصادفی: python -c "import secrets; print(secrets.token_urlsafe(48))"
SECRET_KEY=change-me-to-a-long-random-string
POSTGRES_PASSWORD=change-me-db-password
MINIO_ROOT_USER=aramesh
MINIO_ROOT_PASSWORD=change-me-minio-password
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-admin-password
# --- اختیاری ---
# پورت ورود Traefik در توسعه (پیش‌فرض 5173)
TRAEFIK_HTTP_PORT=5173
# نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example
SITE_URL=
# آدرس‌های مجاز CORS (لیست JSON) — پیش‌فرض فقط لوکال‌هاست
# CORS_ORIGINS=["http://localhost:5173"]
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
# DOMAIN=aramesh.example
# ACME_EMAIL=admin@aramesh.example
+3
View File
@@ -9,6 +9,9 @@ env/
# Environment
.env
# پشتیبان‌گیری محلی
backups/
# Node / Vite
node_modules/
dist/
+59 -8
View File
@@ -28,16 +28,20 @@ Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy
│ └── Caddyfile # فقط سرو فایل‌های استاتیک (مسیریابی روی Traefik است)
├── backend/ # بک‌اند FastAPI
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py)
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
│ ├── tests/ # تست‌های API (pytest)
│ ├── requirements.txt
│ ├── Dockerfile
│ └── .env.example
└── docker-compose.yml # اجرای کامل با یک دستور
├── scripts/backup.sh # پشتیبان‌گیری دیتابیس و تصاویر
├── docker-compose.yml # اجرای کامل با یک دستور
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
```
## اجرا با Docker (ساده‌ترین راه)
```bash
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمی‌آید
docker compose up --build
```
@@ -49,6 +53,18 @@ docker compose up --build
ارتقای نسخه داده‌های فعلی را از بین نمی‌برد
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را می‌سازد و خواندن عمومی را فعال می‌کند
## انتشار واقعی (TLS)
برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید:
```bash
# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
- Traefik گواهی Let's Encrypt می‌گیرد و تمدید می‌کند؛ همه درخواست‌های HTTP به HTTPS هدایت می‌شوند
- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرس‌های OG مطلق شوند
## اجرای محلی (توسعه)
### بک‌اند
@@ -83,13 +99,17 @@ npm run dev
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیش‌فرض: `admin` / `aramesh123`)
و سپس CRUD کامل همه محتواها در `/api/admin/...`
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد می‌شود
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
ذخیره و URL نسبی `/media/...` برمی‌گردد
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسال‌شده عوض می‌شوند
- **ترتیب‌بندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
- همه محتوا در یک درخواست: `GET /api/site`
- همه محتوا در یک درخواست: `GET /api/site` (مقاله‌های پیش‌نویس در آن نمی‌آیند)
- **تست‌ها**: `cd backend && pip install -r requirements-dev.txt && pytest tests`
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید.
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید
> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
## ویرایش زنده سایت (دوقلوی دیجیتال)
@@ -119,6 +139,11 @@ npm run dev
- راست‌به‌چپ کامل با فونت و تم سایت
- ذخیره خودکار: هر مکث کوتاه در تایپ، تغییرها را ذخیره می‌کند؛ بستن کشو هم ذخیره می‌کند
**پیش‌نویس و انتشار**: مقاله تازه همیشه **پیش‌نویس** شروع می‌شود — در سایت عمومی دیده
نمی‌شود و تا انتشار، تغییرهای خودکار آن چیزی را خراب نمی‌کند. با دکمه «انتشار در سایت»
عمومی می‌شود؛ دوباره می‌توان آن را به پیش‌نویس برگرداند. پیش‌نویس‌ها فقط برای ادمین
(در صفحه وبلاگ، با برچسب «پیش‌نویس») دیده می‌شوند و در `GET /api/site` هرگز نمی‌آیند.
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل می‌کند، پس فقط تگ‌های شناخته‌شده
(پاراگراف، تیتر، لیست، نقل‌قول، تصویر، لینک) تولید می‌شوند. مقالات قدیمی (لیست پاراگراف)
@@ -128,14 +153,40 @@ npm run dev
- **بررسی سئو در صفحه نوشتن**: فهرست سبز/نارنجی/قرمز به سبک یوست ولی سازگار با فارسی —
طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیترها)، طول چکیده، تعداد واژه،
تیتربندی H2، متن جایگزین تصاویر، پیوندها و نشانی (slug)
تیتربندی H2، متن جایگزین تصاویر، پیوندها و نشانی (slug) — کلمه کلیدی هدف در دیتابیس
ذخیره می‌شود (فیلد `seo_keyphrase` مقاله)
- **سئوی هر صفحه**: در حالت ویرایش، دکمه سئو روی هیرو هر صفحه (و کنار هیرو صفحه اصلی) —
عنوان تب و توضیح متای هر صفحه از دیتابیس خوانده و اعمال می‌شود
عنوان تب و توضیح متای هر صفحه از دیتابیس خوانده و اعمال می‌شود؛ اگر ردیف سئوی صفحه‌ای
هنوز ساخته نشده باشد، دکمه + همان‌جا آن را می‌سازد
- صفحه مقاله و پروژه به‌طور خودکار عنوان و توضیح متا را از محتوای خودشان می‌گیرند
- **تگ‌های OG و توییتر و canonical** برای همه صفحات از `src/seo.ts` ست می‌شود و
مقاله‌ها اسکیمای JSON-LD نوع Article می‌گیرند
- **robots.txt و sitemap.xml** از بک‌اند سرو می‌شوند و مقاله/پروژه‌های منتشرشده را
شامل می‌شوند (پیش‌نویس‌ها نمی‌آیند) — برای آدرس مطلق، `SITE_URL` را در `.env` بگذارید
- **متا برای خزنده‌های بدون جاوااسکریپت** (تلگرام، واتس‌اپ و...): بک‌اند صفحات
`/blog`، `/blog/{slug}`، `/projects` و `/projects/{slug}` را با همان `index.html`
فرانت‌اند سرو می‌کند و پیش از ارسال، title / description / OG / JSON-LD را از
دیتابیس داخل آن تزریق می‌کند (`app/seo_pages.py`) — بازدیدکننده‌های عادی همان SPA
همیشگی را می‌بینند
## پشتیبان‌گیری
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است — هر دو را با یک دستور پشتیبان بگیرید:
```bash
bash scripts/backup.sh # در backups/ — نگهداری پیش‌فرض ۱۴ روز
```
- خروجی: `backups/db-<زمان>.sql.gz` (فشرده) و `backups/images-<زمان>/` (آینه باکت)
- زمان‌بندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza`
## نکات امنیتی
- فایل `.env` هرگز commit نمی‌شود (در `.gitignore` است)
- رمز عبور ادمین با bcrypt هش می‌شود
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml`
با سینتکس `${VAR:?}` اجباری شده‌اند — بدون `.env` استک بالا نمی‌آید
- رمز عبور ادمین با bcrypt هش می‌شود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
- ورود با محدودیت تلاش محافظت می‌شود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
- توکن‌های JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
- مقاله‌های پیش‌نویس در هیچ انتهای عمومی نمی‌آیند و در HTML تزریق‌شده سئو هم افشا نمی‌شوند
+57
View File
@@ -0,0 +1,57 @@
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورت‌های استاندارد ۸۰/۴۴۳.
# پیش‌نیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ به‌علاوه رکورد DNS دامنه به این سرور.
#
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# همه درخواست‌های HTTP به HTTPS هدایت می‌شوند و مسیریابی‌ها فقط روی دامنه DOMAIN پاسخ می‌دهند.
services:
traefik:
command: !override
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --entrypoints.websecure.address=:443
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:?ACME_EMAIL را در .env تنظیم کنید}
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
ports: !override
- "80:80"
- "443:443"
volumes: !override
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt_data:/letsencrypt
backend:
environment:
# سایت هم‌مبدأ است — CORS لازم نیست
CORS_ORIGINS: '[]'
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=le
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
- traefik.http.routers.seo.entrypoints=websecure
- traefik.http.routers.seo.tls.certresolver=le
minio:
labels:
- traefik.enable=true
- traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
- traefik.http.routers.media.entrypoints=websecure
- traefik.http.routers.media.tls.certresolver=le
frontend:
labels:
- traefik.enable=true
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
- traefik.http.routers.web.entrypoints=websecure
- traefik.http.routers.web.tls.certresolver=le
volumes:
letsencrypt_data:
+51 -14
View File
@@ -1,3 +1,7 @@
# مقادیر محرمانه از فایل .env ریشه خوانده می‌شوند — از .env.example کپی کنید:
# cp .env.example .env
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README).
services:
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بک‌اند) و /media (MinIO)
traefik:
@@ -8,7 +12,7 @@ services:
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
ports:
- "5173:80"
- "${TRAEFIK_HTTP_PORT:-5173}:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
@@ -17,7 +21,7 @@ services:
restart: unless-stopped
environment:
POSTGRES_USER: aramesh
POSTGRES_PASSWORD: aramesh
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
POSTGRES_DB: arameshplaza
volumes:
- db_data:/var/lib/postgresql/data
@@ -32,8 +36,8 @@ services:
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: aramesh
MINIO_ROOT_PASSWORD: aramesh-minio-secret
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
volumes:
- minio_data:/data
healthcheck:
@@ -42,7 +46,7 @@ services:
timeout: 5s
retries: 12
ports:
- "9001:9001" # کنسول MinIO
- "9001:9001" # کنسول MinIO — در محیط واقعی با فایروال ببندید
labels:
- traefik.enable=true
- traefik.http.routers.media.rule=PathPrefix(`/media`)
@@ -60,8 +64,8 @@ services:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: aramesh
MINIO_ROOT_PASSWORD: aramesh-minio-secret
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
MINIO_BUCKET: aramesh-media
restart: "no"
entrypoint: >
@@ -76,27 +80,44 @@ services:
build: ./backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql+psycopg://aramesh:aramesh@db:5432/arameshplaza
SECRET_KEY: change-me-to-a-long-random-string
CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]'
ADMIN_USERNAME: admin
ADMIN_PASSWORD: aramesh123
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
CORS_ORIGINS: ${CORS_ORIGINS:-'["http://localhost:5173","http://127.0.0.1:5173"]'}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD را در .env تنظیم کنید}
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: aramesh
S3_SECRET_KEY: aramesh-minio-secret
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-aramesh}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
S3_BUCKET: aramesh-media
MEDIA_PUBLIC_BASE: /media
# نشانی عمومی سایت — برای sitemap و آدرس مطلق OG (مثل https://aramesh.example)
SITE_URL: ${SITE_URL:-}
# بیلد فرانت‌اند برای تزریق متای سئو در صفحات /blog و /projects
FRONTEND_DIST: /srv/frontend
volumes:
- ./frontend/dist:/srv/frontend:ro
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=web
- traefik.http.routers.api.priority=100
- traefik.http.services.api.loadbalancer.server.port=8000
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
- traefik.http.routers.seo.entrypoints=web
- traefik.http.routers.seo.priority=50
- traefik.http.routers.seo.service=api
# فقط سرو فایل‌های استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
frontend:
@@ -105,6 +126,11 @@ services:
depends_on:
- backend
- minio
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
interval: 15s
timeout: 5s
retries: 5
labels:
- traefik.enable=true
- traefik.http.routers.web.rule=PathPrefix(`/`)
@@ -112,6 +138,17 @@ services:
- traefik.http.routers.web.priority=1
- traefik.http.services.web.loadbalancer.server.port=80
# ابزار پشتیبان‌گیری — فقط با پروفایل tools اجرا می‌شود (scripts/backup.sh این کار را می‌کند)
mc:
image: minio/mc:latest
profiles: ["tools"]
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
volumes:
db_data:
minio_data:
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# پشتیبان‌گیری روزانه — دیتابیس PostgreSQL (همه محتوای سایت) و تصاویر MinIO.
#
# اجرا: bash scripts/backup.sh
# زمان‌بندی: cron (لینوکس) یا Task Scheduler (ویندوز)، مثلا هر شب ساعت ۳:
# 0 3 * * * cd /path/to/aramesh-backend && bash scripts/backup.sh >> backups/backup.log 2>&1
#
# نگهداری: فایل‌های قدیمی‌تر از RETENTION_DAYS (پیش‌فرض ۱۴) حذف می‌شوند.
set -euo pipefail
cd "$(dirname "$0")/.."
RETENTION_DAYS="${RETENTION_DAYS:-14}"
STAMP="$(date +%Y%m%d-%H%M%S)"
mkdir -p backups
# متغیرهای .env (برای MINIO_ROOT_*) در دسترس قرار می‌گیرند
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
echo "[backup] $STAMP"
# ۱) دیتابیس — همه متن‌ها، مقاله‌ها و تنظیمات سایت
DB_FILE="backups/db-${STAMP}.sql.gz"
docker compose exec -T db pg_dump -U aramesh arameshplaza | gzip > "$DB_FILE"
echo "[backup] db: $DB_FILE ($(du -h "$DB_FILE" | cut -f1))"
# ۲) تصاویر MinIO — باکت aramesh-media
docker compose run --rm -v "$(pwd)/backups:/backups" --entrypoint sh mc -c "
mc alias set local http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null &&
mc mirror --overwrite local/aramesh-media /backups/images-${STAMP}
"
echo "[backup] images: backups/images-${STAMP}"
# ۳) پاک‌سازی نسخه‌های قدیمی‌تر
find backups -name 'db-*.sql.gz' -mtime "+${RETENTION_DAYS}" -delete
find backups -maxdepth 1 -type d -name 'images-*' -mtime "+${RETENTION_DAYS}" -exec rm -rf {} +
echo "[backup] پاک‌سازی نسخه‌های قدیمی‌تر از ${RETENTION_DAYS} روز انجام شد"
echo "[backup] ✔ تمام شد"