diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0369333 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 892a7d0..da36e75 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ env/ # Environment .env +# پشتیبان‌گیری محلی +backups/ + # Node / Vite node_modules/ dist/ diff --git a/README.md b/README.md index 55af1d0..0a77754 100644 --- a/README.md +++ b/README.md @@ -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 تزریق‌شده سئو هم افشا نمی‌شوند diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..4784fa5 --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 5de208b..6b93d3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..8065d77 --- /dev/null +++ b/scripts/backup.sh @@ -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] ✔ تمام شد"