2 Commits
Author SHA1 Message Date
MMD 2ad3ad7367 زیرساخت: رمزهای اجباری از .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ها
2026-09-07 04:26:18 +03:30
MMD b31124e1e4 وبلاگ و سئو: پیش‌نویس/انتشار مقاله + کلمه کلیدی سئو در دیتابیس + OG/canonical/JSON-LD و متای سمت سرور + تغییر رمز و محدودسازی ورود + تست‌ها
- مقاله‌ها ستون status (draft/published) گرفتند — GET /api/site و همه انتهای عمومی فقط منتشرشده را برمی‌گردانند؛ autosave دیگر پیش‌نویس نیمه‌کاره را زنده نمی‌کند
- کلمه کلیدی هدف چک‌لیست سئو فیلد seo_keyphrase شد (قبلاً localStorage بود)
- robots.txt و sitemap.xml از بک‌اند + سرو /blog و /projects با تزریق title/description/OG/JSON-LD از دیتابیس برای خزنده‌های بدون JS (app/seo_pages.py)
- فرانت: applySeo در seo.ts (OG/توییتر/canonical/JSON-LD)، ادغام پیش‌نویس برای ادمین، برچسب پیش‌نویس در وبلاگ، حالت خالی وبلاگ، دکمه انتشار در صفحه نوشتن
- تغییر رمز ادمین POST /api/auth/change-password + دیالوگ «رمز» در نوار مدیریت
- محدودسازی ورود: ۵ تلاش ناموفق در ۱۵ دقیقه از هر IP → 429
- ساخت دکمه + برای ردیف سئوی صفحه‌های بدون ردیف (SingletonPill)
- تست‌های pytest (۱۵ مورد) + پین‌کردن requirements + حذف package.json سرگردان فرانت
2026-09-07 04:26:18 +03:30
37 changed files with 1439 additions and 174 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 # Environment
.env .env
# پشتیبان‌گیری محلی
backups/
# Node / Vite # Node / Vite
node_modules/ node_modules/
dist/ dist/
+59 -8
View File
@@ -28,16 +28,20 @@ Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy │ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy
│ └── Caddyfile # فقط سرو فایل‌های استاتیک (مسیریابی روی Traefik است) │ └── Caddyfile # فقط سرو فایل‌های استاتیک (مسیریابی روی Traefik است)
├── backend/ # بک‌اند FastAPI ├── backend/ # بک‌اند FastAPI
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py) │ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
│ ├── tests/ # تست‌های API (pytest)
│ ├── requirements.txt │ ├── requirements.txt
│ ├── Dockerfile │ ├── Dockerfile
│ └── .env.example │ └── .env.example
└── docker-compose.yml # اجرای کامل با یک دستور ├── scripts/backup.sh # پشتیبان‌گیری دیتابیس و تصاویر
├── docker-compose.yml # اجرای کامل با یک دستور
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
``` ```
## اجرا با Docker (ساده‌ترین راه) ## اجرا با Docker (ساده‌ترین راه)
```bash ```bash
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمی‌آید
docker compose up --build docker compose up --build
``` ```
@@ -49,6 +53,18 @@ docker compose up --build
ارتقای نسخه داده‌های فعلی را از بین نمی‌برد ارتقای نسخه داده‌های فعلی را از بین نمی‌برد
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را می‌سازد و خواندن عمومی را فعال می‌کند - در اولین اجرا، سرویس `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` - **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیش‌فرض: `admin` / `aramesh123`) - **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیش‌فرض: `admin` / `aramesh123`)
و سپس CRUD کامل همه محتواها در `/api/admin/...` و سپس CRUD کامل همه محتواها در `/api/admin/...`
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد می‌شود
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO - **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
ذخیره و URL نسبی `/media/...` برمی‌گردد ذخیره و URL نسبی `/media/...` برمی‌گردد
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسال‌شده عوض می‌شوند - **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسال‌شده عوض می‌شوند
- **ترتیب‌بندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}` - **ترتیب‌بندی** `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 (JSON) در PostgreSQL است — نه HTML خام.
صفحه مقاله آن را با رندر استاتیک TipTap به 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` است) - مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml`
- رمز عبور ادمین با bcrypt هش می‌شود با سینتکس `${VAR:?}` اجباری شده‌اند — بدون `.env` استک بالا نمی‌آید
- رمز عبور ادمین با bcrypt هش می‌شود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
- ورود با محدودیت تلاش محافظت می‌شود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
- توکن‌های JWT امضا دارند و مدت اعتبارشان قابل تنظیم است - توکن‌های JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است - تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
- مقاله‌های پیش‌نویس در هیچ انتهای عمومی نمی‌آیند و در HTML تزریق‌شده سئو هم افشا نمی‌شوند
+14 -3
View File
@@ -86,6 +86,13 @@ Authorization: Bearer <access_token>
> نام کاربری و رمز عبور پیش‌فرض از تنظیمات `.env` خوانده می‌شود — حتماً در محیط تولید تغییرشان دهید. > نام کاربری و رمز عبور پیش‌فرض از تنظیمات `.env` خوانده می‌شود — حتماً در محیط تولید تغییرشان دهید.
تغییر رمز (با توکن):
```
POST /api/auth/change-password
{ "current_password": "...", "new_password": "..." }
```
## فهرست انتهاپوینت‌ها ## فهرست انتهاپوینت‌ها
### عمومی (بدون نیاز به توکن) ### عمومی (بدون نیاز به توکن)
@@ -97,8 +104,8 @@ Authorization: Bearer <access_token>
| GET | `/api/services` | خدمات | | GET | `/api/services` | خدمات |
| GET | `/api/projects` | همه پروژه‌ها | | GET | `/api/projects` | همه پروژه‌ها |
| GET | `/api/projects/{slug}` | جزئیات یک پروژه | | GET | `/api/projects/{slug}` | جزئیات یک پروژه |
| GET | `/api/articles` | همه مقالات | | GET | `/api/articles` | مقاله‌های منتشرشده (پیش‌نویس نمی‌آید) |
| GET | `/api/articles/{slug}` | جزئیات یک مقاله | | GET | `/api/articles/{slug}` | جزئیات یک مقاله منتشرشده |
| GET | `/api/testimonials` | نظرات مشتریان | | GET | `/api/testimonials` | نظرات مشتریان |
| GET | `/api/faqs` | سوالات متداول | | GET | `/api/faqs` | سوالات متداول |
| GET | `/api/team` | اعضای تیم | | GET | `/api/team` | اعضای تیم |
@@ -109,7 +116,11 @@ Authorization: Bearer <access_token>
| GET | `/api/site` | **همه محتوا در یک درخواست** | | GET | `/api/site` | **همه محتوا در یک درخواست** |
| POST | `/api/contact` | ثبت پیام تماس | | POST | `/api/contact` | ثبت پیام تماس |
| POST | `/api/newsletter` | عضویت در خبرنامه | | POST | `/api/newsletter` | عضویت در خبرنامه |
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن | | POST | `/api/auth/login` | ورود ادمین و دریافت توکن — بعد از ۵ تلاش ناموفق در ۱۵ دقیقه، 429 |
| GET | `/robots.txt` | قوانین خزنده‌ها |
| GET | `/sitemap.xml` | نقشه سایت — مقاله/پروژه‌های منتشرشده |
| GET | `/blog/{slug}` | HTML صفحه مقاله با متای سئوی تزریق‌شده |
| GET | `/projects/{slug}` | HTML صفحه پروژه با متای سئوی تزریق‌شده |
### مدیریت (نیاز به توکن Bearer) ### مدیریت (نیاز به توکن Bearer)
+23 -3
View File
@@ -4,15 +4,17 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
import boto3 import boto3
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import models, schemas from .. import models, schemas
from ..auth import create_access_token, get_current_admin, verify_password from ..auth import create_access_token, get_current_admin, hash_password, verify_password
from ..config import settings from ..config import settings
from ..database import get_db from ..database import get_db
from ..rate_limit import clear as clear_rate_limit
from ..rate_limit import ensure_allowed, record_failure
from ..schemas import ( from ..schemas import (
ArticleIn, ArticleIn,
ArticleOut, ArticleOut,
@@ -23,6 +25,7 @@ from ..schemas import (
BrandIn, BrandIn,
BrandOut, BrandOut,
BrandPatch, BrandPatch,
ChangePasswordIn,
ContactSectionIn, ContactSectionIn,
ContactSectionOut, ContactSectionOut,
ContactSectionPatch, ContactSectionPatch,
@@ -105,16 +108,33 @@ router = APIRouter(dependencies=[Depends(get_current_admin)])
@auth_router.post("/auth/login", response_model=TokenOut) @auth_router.post("/auth/login", response_model=TokenOut)
def login(payload: LoginIn, db: Session = Depends(get_db)): def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
ensure_allowed(request)
user = db.query(models.AdminUser).filter(models.AdminUser.username == payload.username).first() user = db.query(models.AdminUser).filter(models.AdminUser.username == payload.username).first()
if user is None or not verify_password(payload.password, user.password_hash): if user is None or not verify_password(payload.password, user.password_hash):
record_failure(request)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="نام کاربری یا رمز عبور اشتباه است", detail="نام کاربری یا رمز عبور اشتباه است",
) )
clear_rate_limit(request)
return TokenOut(access_token=create_access_token(user.username)) return TokenOut(access_token=create_access_token(user.username))
@auth_router.post("/auth/change-password")
def change_password(
payload: ChangePasswordIn,
admin: models.AdminUser = Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""تغییر رمز ادمین واردشده — رمز فعلی باید درست باشد."""
if not verify_password(payload.current_password, admin.password_hash):
raise HTTPException(status_code=400, detail="رمز عبور فعلی اشتباه است")
admin.password_hash = hash_password(payload.new_password)
db.commit()
return {"ok": True}
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None: def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی، """ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
حذف و ترتیب‌بندی) روی روتر مدیریت.""" حذف و ترتیب‌بندی) روی روتر مدیریت."""
+16 -3
View File
@@ -55,12 +55,21 @@ def get_project(slug: str, db: Session = Depends(get_db)):
@router.get("/articles", response_model=list[ArticleOut]) @router.get("/articles", response_model=list[ArticleOut])
def get_articles(db: Session = Depends(get_db)): def get_articles(db: Session = Depends(get_db)):
return db.query(models.Article).order_by(models.Article.position, models.Article.id).all() return (
db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all()
)
@router.get("/articles/{slug}", response_model=ArticleOut) @router.get("/articles/{slug}", response_model=ArticleOut)
def get_article(slug: str, db: Session = Depends(get_db)): def get_article(slug: str, db: Session = Depends(get_db)):
article = db.query(models.Article).filter(models.Article.slug == slug).first() article = (
db.query(models.Article)
.filter(models.Article.slug == slug, models.Article.status == "published")
.first()
)
if article is None: if article is None:
raise HTTPException(status_code=404, detail="مقاله یافت نشد") raise HTTPException(status_code=404, detail="مقاله یافت نشد")
return article return article
@@ -116,7 +125,11 @@ def get_site(db: Session = Depends(get_db)):
"page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(), "page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(),
"services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(), "services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(),
"projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(), "projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(),
"articles": db.query(models.Article).order_by(models.Article.position, models.Article.id).all(), # فقط مقاله‌های منتشرشده — پیش‌نویس‌ها از انتهای مدیریتی خوانده می‌شوند
"articles": db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all(),
"testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(), "testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(),
"faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(), "faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(),
"team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(), "team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(),
+6 -1
View File
@@ -23,9 +23,14 @@ class Settings(BaseSettings):
s3_access_key: str = "aramesh" s3_access_key: str = "aramesh"
s3_secret_key: str = "aramesh-minio-secret" s3_secret_key: str = "aramesh-minio-secret"
s3_bucket: str = "aramesh-media" s3_bucket: str = "aramesh-media"
# پیشوند عمومی مسیر تصاویر — nginx آن را به باکت پروکسی می‌کند # پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی می‌کند
media_public_base: str = "/media" media_public_base: str = "/media"
media_max_upload_mb: int = 10 media_max_upload_mb: int = 10
# نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
site_url: str = ""
# مسیر بیلد فرانت‌اند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
frontend_dist: str = "../frontend/dist"
settings = Settings() settings = Settings()
+3
View File
@@ -6,6 +6,7 @@ from .api import admin, forms, public
from .config import settings from .config import settings
from .database import Base, engine from .database import Base, engine
from .migrations import run_all as run_migrations from .migrations import run_all as run_migrations
from .seo_pages import router as seo_router
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_migrations() run_migrations()
@@ -33,6 +34,8 @@ app.include_router(public.router, prefix="/api", tags=["عمومی"])
app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"]) app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"])
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"]) app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"]) app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"])
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
app.include_router(seo_router, tags=["سئو"])
@app.get("/") @app.get("/")
+7
View File
@@ -25,6 +25,13 @@ def run_ddl_migrations() -> None:
conn.execute( conn.execute(
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0") text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0")
) )
# مقالات موجود منتشر شده‌اند؛ فقط مقاله‌های تازه می‌توانند پیش‌نویس باشند
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'published'")
)
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
)
def paragraphs_to_doc(paragraphs: list) -> dict: def paragraphs_to_doc(paragraphs: list) -> dict:
+4
View File
@@ -78,6 +78,10 @@ class Article(Base):
read: Mapped[str] = mapped_column(String(32), default="") read: Mapped[str] = mapped_column(String(32), default="")
excerpt: Mapped[str] = mapped_column(Text, default="") excerpt: Mapped[str] = mapped_column(Text, default="")
body: Mapped[list] = mapped_column(JSON, default=list) body: Mapped[list] = mapped_column(JSON, default=list)
# draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود
status: Mapped[str] = mapped_column(String(16), default="published")
# کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود
seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
class Testimonial(Base): class Testimonial(Base):
+49
View File
@@ -0,0 +1,49 @@
"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login.
پیش‌فرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک می‌کند.
شمارنده در حافظه همین پروسه است — با ری‌استارت بک‌اند پاک می‌شود و همین کافی است.
"""
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:
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
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])))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="تلاش‌های ناموفق زیاد است — بعداً دوباره امتحان کنید",
headers={"Retry-After": str(retry_after)},
)
def record_failure(request: Request) -> None:
_failed.setdefault(client_ip(request), []).append(time.monotonic())
def clear(request: Request) -> None:
"""با ورود موفق، تاریخچه تلاش‌های ناموفق IP پاک می‌شود."""
_failed.pop(client_ip(request), None)
+11
View File
@@ -59,6 +59,8 @@ class ArticleOut(ORMModel):
read: str read: str
excerpt: str excerpt: str
body: dict body: dict
status: str
seo_keyphrase: str
class TestimonialOut(ORMModel): class TestimonialOut(ORMModel):
@@ -292,6 +294,11 @@ class TokenOut(BaseModel):
token_type: str = "bearer" token_type: str = "bearer"
class ChangePasswordIn(BaseModel):
current_password: str
new_password: str = Field(min_length=8, max_length=128)
class BrandIn(BaseModel): class BrandIn(BaseModel):
name: str = "" name: str = ""
logo: str = "" logo: str = ""
@@ -346,6 +353,8 @@ class ArticleIn(BaseModel):
read: str = "" read: str = ""
excerpt: str = "" excerpt: str = ""
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC)) body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
status: Literal["draft", "published"] = "draft"
seo_keyphrase: str = Field(default="", max_length=128)
_validate_body = field_validator("body")(validate_doc_body) _validate_body = field_validator("body")(validate_doc_body)
@@ -548,6 +557,8 @@ class ArticlePatch(BaseModel):
read: Optional[str] = None read: Optional[str] = None
excerpt: Optional[str] = None excerpt: Optional[str] = None
body: Optional[dict] = None body: Optional[dict] = None
status: Optional[Literal["draft", "published"]] = None
seo_keyphrase: Optional[str] = None
_validate_body = field_validator("body")(validate_doc_body) _validate_body = field_validator("body")(validate_doc_body)
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn) TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
FaqPatch = _make_patch("FaqPatch", FaqIn) FaqPatch = _make_patch("FaqPatch", FaqIn)
+239
View File
@@ -0,0 +1,239 @@
"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریق‌شده.
فرانت‌اند یک SPA است؛ خزنده‌های اجتماعی (تلگرام، واتس‌اپ و...) جاوااسکریپت اجرا نمی‌کنند.
این ماژول همان index.html بیلد فرانت‌اند را می‌خواند و پیش از ارسال، تگ‌های
title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق می‌کند —
بازدیدکننده‌های عادی همان SPA همیشگی را می‌بینند و خزنده‌ها متای درست را می‌خوانند.
"""
import html
import json
import re
from pathlib import Path
from fastapi import APIRouter, Depends, Response
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from . import models
from .config import settings
from .database import get_db
router = APIRouter()
STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"]
def _absolute(path: str) -> str:
if not path:
return ""
if path.startswith(("http://", "https://")):
return path
site = settings.site_url.rstrip("/")
return f"{site}{path}" if site else path
def _read_index() -> str | None:
try:
return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8")
except OSError:
return None
def _doc_text(doc: object, limit: int = 300) -> str:
"""متن ساده سند TipTap — برای noscript و پیش‌نمایش."""
words: list[str] = []
def walk(node: object) -> None:
if not isinstance(node, dict):
return
if node.get("type") == "text" and isinstance(node.get("text"), str):
words.append(node["text"])
for child in node.get("content") or []:
walk(child)
if isinstance(doc, dict):
for block in doc.get("content") or []:
walk(block)
text = " ".join(words).strip()
return text[:limit] + ("…" if len(text) > limit else "")
def _shell(
*,
title: str,
description: str,
path: str,
og_image: str = "",
og_type: str = "website",
jsonld: dict | None = None,
noscript: str = "",
) -> HTMLResponse:
"""index.html فرانت‌اند با متای تزریق‌شده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
title = title.strip() or "گروه ساختمانی آرامش"
description = description.strip()
canonical = _absolute(path)
image = _absolute(og_image)
esc = html.escape
# تگ title جای تگ موجود index.html را می‌گیرد و در بلوک تکرار نمی‌شود
tags = [
f'<meta name="description" content="{esc(description)}" />',
f'<meta property="og:title" content="{esc(title)}" />',
f'<meta property="og:description" content="{esc(description)}" />',
f'<meta property="og:type" content="{esc(og_type)}" />',
f'<meta property="og:url" content="{esc(canonical)}" />',
f'<meta name="twitter:card" content="summary_large_image" />',
]
if image:
tags.append(f'<meta property="og:image" content="{esc(image)}" />')
if jsonld:
payload = json.dumps(jsonld, ensure_ascii=False).replace("</", "<\\/")
tags.append(f'<script type="application/ld+json">{payload}</script>')
block = "\n ".join(tags)
raw = _read_index()
if raw is None:
# بیلد فرانت‌اند سوار نشده — صفحه حداقلی با خودِ متا تا خزنده‌ها دست خالی نمانند
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
return HTMLResponse(
f'<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">'
f"<title>{esc(title)}</title>{block}</head><body>{body}</body></html>"
)
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
raw = raw.replace("</head>", f" {block}\n </head>", 1)
if noscript:
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
return HTMLResponse(raw)
def _brand_name(db: Session) -> str:
brand = db.query(models.Brand).first()
return brand.name if brand else "گروه ساختمانی آرامش"
def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None:
return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first()
# ---------- robots.txt و sitemap.xml ----------
@router.get("/robots.txt", response_class=Response)
def robots(db: Session = Depends(get_db)):
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
if settings.site_url:
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8")
@router.get("/sitemap.xml", response_class=Response)
def sitemap(db: Session = Depends(get_db)):
urls = list(STATIC_PATHS)
urls += [
f"/blog/{a.slug}"
for a in db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all()
]
urls += [
f"/projects/{p.slug}"
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
]
items = "\n".join(
f" <url><loc>{html.escape(_absolute(u))}</loc></url>" for u in urls
)
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
f"{items}\n</urlset>\n"
)
return Response(content=xml, media_type="application/xml; charset=utf-8")
# ---------- صفحات وبلاگ ----------
@router.get("/blog", response_class=HTMLResponse)
def blog_list(db: Session = Depends(get_db)):
meta = _page_meta(db, "blog")
return _shell(
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
description=meta.description if meta else "",
path="/blog",
og_image=meta.image if meta else "",
)
@router.get("/blog/{slug}", response_class=HTMLResponse)
def blog_detail(slug: str, db: Session = Depends(get_db)):
article = (
db.query(models.Article)
.filter(models.Article.slug == slug, models.Article.status == "published")
.first()
)
if article is None:
# مقاله ناموجود یا پیش‌نویس — پوسته ساده بدون متا؛ SPA پیام «یافت نشد» را نشان می‌دهد
return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}")
brand = _brand_name(db)
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
jsonld = {
"@context": "https://schema.org",
"@type": "Article",
"headline": article.title,
"description": article.excerpt,
"image": _absolute(article.image),
"inLanguage": "fa",
"author": {"@type": "Organization", "name": brand},
"publisher": {"@type": "Organization", "name": brand},
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
}
noscript = f"<h1>{html.escape(article.title)}</h1><p>{html.escape(first_text)}</p>"
return _shell(
title=f"{article.title} | {brand}",
description=article.excerpt,
path=f"/blog/{article.slug}",
og_image=article.image,
og_type="article",
jsonld=jsonld,
noscript=noscript,
)
# ---------- صفحات پروژه ----------
@router.get("/projects", response_class=HTMLResponse)
def projects_list(db: Session = Depends(get_db)):
meta = _page_meta(db, "projects")
return _shell(
title=meta.title if meta and meta.title else "پروژه‌ها | " + _brand_name(db),
description=meta.description if meta else "",
path="/projects",
)
@router.get("/projects/{slug}", response_class=HTMLResponse)
def project_detail(slug: str, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.slug == slug).first()
if project is None:
return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}")
brand = _brand_name(db)
return _shell(
title=f"{project.title} | {brand}",
description=project.summary,
path=f"/projects/{project.slug}",
og_image=project.image,
jsonld={
"@context": "https://schema.org",
"@type": "WebPage",
"name": project.title,
"description": project.summary,
"image": _absolute(project.image),
"inLanguage": "fa",
},
)
+4
View File
@@ -0,0 +1,4 @@
# وابستگی‌های توسعه و تست — pip install -r requirements-dev.txt
-r requirements.txt
pytest==8.3.4
httpx==0.27.2
+13 -11
View File
@@ -1,11 +1,13 @@
fastapi>=0.115.8 # نسخه‌ها دقیق پین شده‌اند تا ارتقای خودکار یک وابستگی، استقرار را نشکند.
uvicorn[standard]>=0.34.0 # ارتقا: نسخه را دستی بالا ببرید و روی محیط آماده‌سازی تست کنید.
sqlalchemy>=2.0.36 fastapi==0.115.8
psycopg[binary]>=3.2.3 uvicorn[standard]==0.34.0
pydantic>=2.10.4 sqlalchemy==2.0.36
pydantic-settings>=2.6.1 psycopg[binary]==3.2.3
PyJWT>=2.10.1 pydantic==2.10.4
bcrypt>=4.2.1 pydantic-settings==2.6.1
email-validator>=2.2.0 PyJWT==2.10.1
boto3>=1.35.0 bcrypt==4.2.1
python-multipart>=0.0.18 email-validator==2.2.0
boto3==1.35.0
python-multipart==0.0.18
+93
View File
@@ -0,0 +1,93 @@
"""پیکربندی تست‌ها — دیتابیس SQLite فایل‌محور و داده‌های حداقلی.
متغیرهای محیطی پیش از ایمپورت app تنظیم می‌شوند تا settings همان‌ها را بخواند.
"""
import os
import pathlib
os.environ["DATABASE_URL"] = "sqlite:///./test.db?check_same_thread=false"
os.environ["SECRET_KEY"] = "test-secret-key"
os.environ["ADMIN_USERNAME"] = "admin"
os.environ["ADMIN_PASSWORD"] = "test-admin-pass-1"
os.environ["SITE_URL"] = "https://test.example"
import pytest
from fastapi.testclient import TestClient
from app import models
from app.auth import hash_password
from app.config import settings
from app.database import Base, SessionLocal, engine
from app.main import app
TEST_DB = pathlib.Path("test.db")
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
@pytest.fixture(scope="session", autouse=True)
def seed_db(tmp_path_factory):
# اتصالات ساخت جدول در ایمپورت ماژول باید بسته شوند تا فایل قدیمی حذف شود
engine.dispose()
if TEST_DB.exists():
TEST_DB.unlink()
Base.metadata.create_all(bind=engine)
# dist ساختگی فرانت‌اند برای تست تزریق متا در صفحات جزئیات
dist = tmp_path_factory.mktemp("dist")
(dist / "index.html").write_text(
"<!doctype html><html><head><title>Shell</title></head>"
"<body><div id=\"root\"></div></body></html>",
encoding="utf-8",
)
settings.frontend_dist = str(dist)
settings.site_url = "https://test.example"
db = SessionLocal()
try:
db.add(models.AdminUser(username="admin", password_hash=hash_password("test-admin-pass-1")))
db.add(models.Brand(name="گروه ساختمانی آرامش"))
db.add(
models.Article(
slug="published-post", title="مقاله منتشرشده", status="published",
excerpt="چکیده مقاله", body=DOC, position=1,
)
)
db.add(
models.Article(
slug="draft-post", title="پیش‌نویس", status="draft",
excerpt="چکیده پیش‌نویس", body=DOC, position=2,
)
)
db.commit()
finally:
db.close()
yield
db.close()
engine.dispose()
if TEST_DB.exists():
TEST_DB.unlink()
@pytest.fixture(autouse=True)
def reset_rate_limiter():
from app import rate_limit
rate_limit._failed.clear()
yield
rate_limit._failed.clear()
@pytest.fixture()
def client():
return TestClient(app)
@pytest.fixture()
def auth_client(client):
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
assert res.status_code == 200, res.text
token = res.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
client.headers.pop("Authorization", None)
+182
View File
@@ -0,0 +1,182 @@
"""تست‌های API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیش‌نویس مقاله و سئو."""
import re
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
# ---------- عمومی ----------
def test_health(client):
res = client.get("/api/health")
assert res.status_code == 200
assert res.json()["status"] == "ok"
def test_site_hides_draft_articles(client):
res = client.get("/api/site")
assert res.status_code == 200
slugs = [a["slug"] for a in res.json()["articles"]]
assert "published-post" in slugs
assert "draft-post" not in slugs
def test_public_articles_hide_drafts(client):
res = client.get("/api/articles")
assert all(a["status"] == "published" for a in res.json())
assert client.get("/api/articles/draft-post").status_code == 404
def test_admin_endpoints_reject_anonymous(client):
assert client.get("/api/admin/articles").status_code == 401
# ---------- احراز هویت ----------
def test_login_success_and_failure(client):
assert (
client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}).status_code
== 401
)
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
assert res.status_code == 200
assert res.json()["access_token"]
def test_change_password(auth_client, client):
res = auth_client.post(
"/api/auth/change-password",
json={"current_password": "wrong", "new_password": "brand-new-pass-9"},
)
assert res.status_code == 400
res = auth_client.post(
"/api/auth/change-password",
json={"current_password": "test-admin-pass-1", "new_password": "brand-new-pass-9"},
)
assert res.status_code == 200
# رمز قدیمی رد و رمز جدید قبول می‌شود
assert (
client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"}).status_code
== 401
)
assert (
client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"}).status_code
== 200
)
# بازگرداندن رمز برای تست‌های بعدی
res = client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"})
auth = {"Authorization": f"Bearer {res.json()['access_token']}"}
res = client.post(
"/api/auth/change-password",
json={"current_password": "brand-new-pass-9", "new_password": "test-admin-pass-1"},
headers=auth,
)
assert res.status_code == 200
def test_login_rate_limited(client):
for _ in range(5):
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 401
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 429
assert "Retry-After" in res.headers
# ---------- CRUD مدیریت ----------
def test_article_crud_and_draft_flow(auth_client, client):
created = auth_client.post(
"/api/admin/articles",
json={"slug": "flow-post", "title": "مقاله جریان", "body": DOC},
)
assert created.status_code == 201, created.text
article = created.json()
# مقاله تازه پیش‌نویس است و در سایت عمومی دیده نمی‌شود
assert article["status"] == "draft"
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"seo_keyphrase": "ساختمان"})
assert res.json()["seo_keyphrase"] == "ساختمان"
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"status": "published"})
assert res.status_code == 200
assert "flow-post" in [a["slug"] for a in client.get("/api/articles").json()]
assert auth_client.delete(f"/api/admin/articles/{article['id']}").status_code == 204
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
def test_service_roundtrip_and_reorder(auth_client):
ids = []
for name in ("خدمت الف", "خدمت ب"):
res = auth_client.post("/api/admin/services", json={"title": name})
assert res.status_code == 201
ids.append(res.json()["id"])
res = auth_client.post("/api/admin/services/reorder", json={"ids": ids[::-1]})
assert res.status_code == 200
ordered = [s["id"] for s in res.json() if s["id"] in ids]
assert ordered == ids[::-1]
res = auth_client.patch(f"/api/admin/services/{ids[0]}", json={"desc": "توضیح تازه"})
assert res.json()["desc"] == "توضیح تازه"
for i in ids:
assert auth_client.delete(f"/api/admin/services/{i}").status_code == 204
def test_seo_meta_create_and_patch(auth_client):
res = auth_client.post(
"/api/admin/seo-meta", json={"page_key": "test-page", "title": "عنوان", "description": "توضیح"}
)
assert res.status_code == 201
meta_id = res.json()["id"]
res = auth_client.patch(f"/api/admin/seo-meta/{meta_id}", json={"title": "عنوان تازه"})
assert res.json()["title"] == "عنوان تازه"
assert auth_client.delete(f"/api/admin/seo-meta/{meta_id}").status_code == 204
# ---------- سئو ----------
def test_robots_txt(client):
res = client.get("/robots.txt")
assert res.status_code == 200
assert "Disallow: /admin" in res.text
assert "Sitemap: https://test.example/sitemap.xml" in res.text
def test_sitemap_includes_published_only(client):
res = client.get("/sitemap.xml")
assert res.status_code == 200
assert "/blog/published-post" in res.text
assert "/blog/draft-post" not in res.text
assert res.text.startswith("<?xml")
def test_head_injection_for_article(client):
res = client.get("/blog/published-post")
assert res.status_code == 200
assert "مقاله منتشرشده" in res.text
assert 'property="og:title"' in res.text
assert "application/ld+json" in res.text
assert "https://test.example/blog/published-post" in res.text
def test_head_not_injected_for_draft(client):
res = client.get("/blog/draft-post")
assert res.status_code == 200
assert "پیش‌نویس" not in res.text # عنوان پیش‌نویس افشا نمی‌شود
assert "application/ld+json" not in res.text
def test_head_replaces_title_once(client):
res = client.get("/blog/published-post")
assert len(re.findall(r"<title>", res.text)) == 1
+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: services:
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بک‌اند) و /media (MinIO) # نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بک‌اند) و /media (MinIO)
traefik: traefik:
@@ -8,7 +12,7 @@ services:
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80 - --entrypoints.web.address=:80
ports: ports:
- "5173:80" - "${TRAEFIK_HTTP_PORT:-5173}:80"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
@@ -17,7 +21,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: aramesh POSTGRES_USER: aramesh
POSTGRES_PASSWORD: aramesh POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
POSTGRES_DB: arameshplaza POSTGRES_DB: arameshplaza
volumes: volumes:
- db_data:/var/lib/postgresql/data - db_data:/var/lib/postgresql/data
@@ -32,8 +36,8 @@ services:
restart: unless-stopped restart: unless-stopped
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
environment: environment:
MINIO_ROOT_USER: aramesh MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: aramesh-minio-secret MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
volumes: volumes:
- minio_data:/data - minio_data:/data
healthcheck: healthcheck:
@@ -42,7 +46,7 @@ services:
timeout: 5s timeout: 5s
retries: 12 retries: 12
ports: ports:
- "9001:9001" # کنسول MinIO - "9001:9001" # کنسول MinIO — در محیط واقعی با فایروال ببندید
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.media.rule=PathPrefix(`/media`) - traefik.http.routers.media.rule=PathPrefix(`/media`)
@@ -60,8 +64,8 @@ services:
minio: minio:
condition: service_healthy condition: service_healthy
environment: environment:
MINIO_ROOT_USER: aramesh MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: aramesh-minio-secret MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
MINIO_BUCKET: aramesh-media MINIO_BUCKET: aramesh-media
restart: "no" restart: "no"
entrypoint: > entrypoint: >
@@ -76,27 +80,44 @@ services:
build: ./backend build: ./backend
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql+psycopg://aramesh:aramesh@db:5432/arameshplaza DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
SECRET_KEY: change-me-to-a-long-random-string SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]' CORS_ORIGINS: ${CORS_ORIGINS:-'["http://localhost:5173","http://127.0.0.1:5173"]'}
ADMIN_USERNAME: admin ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: aramesh123 ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD را در .env تنظیم کنید}
S3_ENDPOINT: http://minio:9000 S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: aramesh S3_ACCESS_KEY: ${MINIO_ROOT_USER:-aramesh}
S3_SECRET_KEY: aramesh-minio-secret S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
S3_BUCKET: aramesh-media S3_BUCKET: aramesh-media
MEDIA_PUBLIC_BASE: /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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
minio: minio:
condition: service_healthy 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: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.api.rule=PathPrefix(`/api`) - traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=web - traefik.http.routers.api.entrypoints=web
- traefik.http.routers.api.priority=100 - traefik.http.routers.api.priority=100
- traefik.http.services.api.loadbalancer.server.port=8000 - 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) انجام شده است # فقط سرو فایل‌های استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
frontend: frontend:
@@ -105,6 +126,11 @@ services:
depends_on: depends_on:
- backend - backend
- minio - minio
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
interval: 15s
timeout: 5s
retries: 5
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.web.rule=PathPrefix(`/`) - traefik.http.routers.web.rule=PathPrefix(`/`)
@@ -112,6 +138,17 @@ services:
- traefik.http.routers.web.priority=1 - traefik.http.routers.web.priority=1
- traefik.http.services.web.loadbalancer.server.port=80 - 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: volumes:
db_data: db_data:
minio_data: minio_data:
+30 -5
View File
@@ -1,8 +1,33 @@
# Magic Patterns - Vite Template # فرانت‌اند گروه ساختمانی آرامش
This code was generated by [Magic Patterns](https://magicpatterns.com) for this design: [Source Design](https://www.magicpatterns.com/c/ka4i8ikygtakmdobhws6nu) سایت React + Vite + Tailwind — تمام متن‌ها و تصاویر از بک‌اند (`GET /api/site`) خوانده می‌شود.
## Getting Started ## اجرا
1. Run `npm install` ```bash
2. Run `npm run dev` npm install
npm run dev
```
در حالت توسعه، Vite درخواست‌های `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی می‌کند —
پس استک Docker باید در حال اجرا باشد. آدرس بک‌اند در `src/config.ts` از `VITE_API_URL`
خوانده می‌شود؛ پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ.
## ساخت
بیلد روی هاست انجام می‌شود (کانتینر فقط با Caddy فایل‌های `dist/` را سرو می‌کند):
```bash
npm run build
```
> نکته: بک‌اند برای تزریق متای سئو در صفحات `/blog` و `/projects` همین پوشه `dist/` را
> (در compose به `/srv/frontend` سوار است) می‌خواند — بعد از هر تغییر فرانت، `npm run build`
> و سپس ری‌استارت بک‌اند کافی است.
## بخش‌های کلیدی
- `src/api.tsx` — بارگذاری داده سایت و ادغام پیش‌نویس مقاله‌ها برای ادمین
- `src/admin/` — لایه ویرایش زنده (دوقلوی دیجیتال): نوار مدیریت، کشوی موجودیت‌ها، صفحه نوشتن مقاله
- `src/seo.ts` — تگ‌های title / description / OG / canonical / JSON-LD هر صفحه
- `src/data/site.ts` — انواع داده مشترک
+8
View File
@@ -5,6 +5,14 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>گروه ساختمانی آرامش</title> <title>گروه ساختمانی آرامش</title>
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژه‌های ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایه‌گذاری ساخت‌وساز." />
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Organization","name":"گروه ساختمانی آرامش","inLanguage":"fa"}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+6 -3
View File
@@ -16,7 +16,7 @@ import { ArticleDetail } from './pages/ArticleDetail';
import { FaqPage } from './pages/FaqPage'; import { FaqPage } from './pages/FaqPage';
import { ContactPage } from './pages/ContactPage'; import { ContactPage } from './pages/ContactPage';
import { SiteProvider, useSiteData } from './api'; import { SiteProvider, useSiteData } from './api';
import { seoKeyForPath, setMetaDescription } from './seo'; import { applySeo, seoKeyForPath } from './seo';
import { AdminProvider, useAdmin } from './admin/AdminContext'; import { AdminProvider, useAdmin } from './admin/AdminContext';
import { AdminBar } from './admin/AdminBar'; import { AdminBar } from './admin/AdminBar';
import { AdminLoginPage } from './admin/AdminLoginPage'; import { AdminLoginPage } from './admin/AdminLoginPage';
@@ -76,8 +76,11 @@ function AppChrome() {
const meta = data.seo_meta?.find((s) => s.page_key === key); const meta = data.seo_meta?.find((s) => s.page_key === key);
const brand = data.brand.name; const brand = data.brand.name;
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label; const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
document.title = meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand); applySeo({
setMetaDescription(meta?.description ?? ''); title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
description: meta?.description ?? '',
path: pathname,
});
}, [pathname, data, isAdminPage]); }, [pathname, data, isAdminPage]);
return ( return (
+170 -44
View File
@@ -1,4 +1,8 @@
import { useState } from 'react'
import Swal from 'sweetalert2'
import { KeyRoundIcon, XIcon } from 'lucide-react'
import { useAdmin } from './AdminContext' import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
const STATUS_TEXT: Record<string, string> = { const STATUS_TEXT: Record<string, string> = {
idle: 'همگام‌سازی شد', idle: 'همگام‌سازی شد',
@@ -12,53 +16,175 @@ const STATUS_COLOR: Record<string, string> = {
error: 'bg-red-400', error: 'bg-red-400',
} }
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */ const SWAL_BASE = {
export function AdminBar() { background: '#1e1a16',
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin() color: '#F5F3EF',
iconColor: '#d5874c',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10 rounded-none',
title: 'font-pinar font-bold text-lg text-ivory',
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
},
}
function ChangePasswordModal({ onClose }: { onClose: () => void }) {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
if (next.length < 8) {
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'رمز جدید کوتاه است', text: 'رمز جدید باید دست‌کم ۸ نویسه باشد.', confirmButtonText: 'باشه' })
return
}
if (next !== confirm) {
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'تکرار رمز مطابق نیست', text: 'رمز جدید و تکرار آن یکسان نیستند.', confirmButtonText: 'باشه' })
return
}
setBusy(true)
try {
await adminApi.changePassword(current, next)
void Swal.fire({ ...SWAL_BASE, icon: 'success', title: 'رمز عبور عوض شد', text: 'از این به بعد با رمز جدید وارد شوید.', iconColor: '#34d399', confirmButtonText: 'باشه' })
onClose()
} catch (error) {
void Swal.fire({
...SWAL_BASE,
icon: 'error',
title: 'تغییر رمز ناموفق بود',
text: error instanceof Error ? error.message : 'دوباره تلاش کنید',
confirmButtonText: 'باشه',
})
} finally {
setBusy(false)
}
}
const field =
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none'
return ( return (
<div <div className="fixed inset-0 z-[95] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="تغییر رمز عبور">
dir="rtl" <div dir="rtl" className="w-full max-w-sm border border-ivory/15 bg-ink p-8 shadow-2xl">
className="fixed bottom-4 right-1/2 z-[90] flex translate-x-1/2 items-center gap-5 border border-ivory/15 bg-ink/95 py-2.5 pl-6 pr-5 shadow-2xl backdrop-blur-md" <div className="flex items-center justify-between">
> <h2 className="flex items-center gap-2 text-sm font-bold text-copper">
<span className="flex items-center gap-2 text-xs font-bold text-copper"> <KeyRoundIcon className="h-4 w-4" />
<span className="flex h-2 w-2 rounded-full bg-copper" /> تغییر رمز عبور
حالت مدیریت </h2>
</span> <button
type="button"
<button onClick={onClose}
type="button" aria-label="بستن"
role="switch" className="text-ivory/50 transition-colors hover:text-ivory"
aria-checked={isEditMode} >
onClick={toggleEditMode} <XIcon className="h-4 w-4" />
className="group flex items-center gap-3 text-sm font-light text-ivory/80" </button>
> </div>
<span <div className="mt-6 grid gap-4">
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${ <input
isEditMode ? 'border-copper bg-copper/30' : 'border-ivory/25 bg-char' type="password"
}`} value={current}
> onChange={(e) => setCurrent(e.target.value)}
<span placeholder="رمز عبور فعلی"
className={`absolute h-4 w-4 rounded-full transition-all duration-200 ${ aria-label="رمز عبور فعلی"
isEditMode ? 'right-1 bg-copper' : 'right-6 bg-ivory/50' autoComplete="current-password"
}`} className={field}
/> />
</span> <input
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'} type="password"
</button> value={next}
onChange={(e) => setNext(e.target.value)}
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60"> placeholder="رمز جدید (دست‌کم ۸ نویسه)"
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" /> aria-label="رمز جدید"
{STATUS_TEXT[saveStatus]} autoComplete="new-password"
</span> className={field}
/>
<button <input
type="button" type="password"
onClick={logout} value={confirm}
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper" onChange={(e) => setConfirm(e.target.value)}
> placeholder="تکرار رمز جدید"
خروج aria-label="تکرار رمز جدید"
</button> autoComplete="new-password"
className={field}
/>
</div>
<button
type="button"
onClick={() => void submit()}
disabled={busy || !current || !next}
className="mt-6 w-full bg-copper py-2.5 text-sm font-bold text-char transition-opacity disabled:opacity-50"
>
{busy ? 'در حال ذخیره…' : 'ذخیره رمز جدید'}
</button>
</div>
</div> </div>
) )
} }
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */
export function AdminBar() {
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
const [pwOpen, setPwOpen] = useState(false)
return (
<>
<div
dir="rtl"
className="fixed bottom-4 right-1/2 z-[90] flex translate-x-1/2 items-center gap-5 border border-ivory/15 bg-ink/95 py-2.5 pl-6 pr-5 shadow-2xl backdrop-blur-md"
>
<span className="flex items-center gap-2 text-xs font-bold text-copper">
<span className="flex h-2 w-2 rounded-full bg-copper" />
حالت مدیریت
</span>
<button
type="button"
role="switch"
aria-checked={isEditMode}
onClick={toggleEditMode}
className="group flex items-center gap-3 text-sm font-light text-ivory/80"
>
<span
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${
isEditMode ? 'border-copper bg-copper/30' : 'border-ivory/25 bg-char'
}`}
>
<span
className={`absolute h-4 w-4 rounded-full transition-all duration-200 ${
isEditMode ? 'right-1 bg-copper' : 'right-6 bg-ivory/50'
}`}
/>
</span>
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
</button>
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
{STATUS_TEXT[saveStatus]}
</span>
<button
type="button"
onClick={() => setPwOpen(true)}
className="flex items-center gap-1.5 border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
<KeyRoundIcon className="h-3.5 w-3.5" />
رمز
</button>
<button
type="button"
onClick={logout}
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
خروج
</button>
</div>
{pwOpen && <ChangePasswordModal onClose={() => setPwOpen(false)} />}
</>
)
}
+6 -2
View File
@@ -67,14 +67,18 @@ export function AdminProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (username: string, password: string) => { const login = useCallback(async (username: string, password: string) => {
await adminApi.login(username, password) await adminApi.login(username, password)
setIsAuthenticated(true) setIsAuthenticated(true)
}, []) // داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس مقاله‌ها هم ادغام شوند
reload()
}, [reload])
const logout = useCallback(() => { const logout = useCallback(() => {
setToken(null) setToken(null)
setIsAuthenticated(false) setIsAuthenticated(false)
setIsEditMode(false) setIsEditMode(false)
sessionStorage.removeItem('aramesh_edit_mode') sessionStorage.removeItem('aramesh_edit_mode')
}, []) // داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس‌ها از درخت داده حذف شوند
reload()
}, [reload])
const runSave = useCallback( const runSave = useCallback(
async (action: () => Promise<void>): Promise<boolean> => { async (action: () => Promise<void>): Promise<boolean> => {
+79 -32
View File
@@ -22,6 +22,8 @@ type Draft = {
read: string read: string
excerpt: string excerpt: string
body: ArticleBodyDoc body: ArticleBodyDoc
status: 'draft' | 'published'
seo_keyphrase: string
} }
const STATUS_TEXT: Record<string, string> = { const STATUS_TEXT: Record<string, string> = {
@@ -41,34 +43,24 @@ export function ArticleWritePage() {
const { data, updateSite } = useSiteData() const { data, updateSite } = useSiteData()
const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined
const [fetched, setFetched] = useState<Draft | null>(null)
const [draft, setDraft] = useState<Draft>(() => // مقاله تازه همیشه پیش‌نویس شروع می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود
existing const [draft, setDraft] = useState<Draft>({
? { id: 0,
...existing, slug: `article-${Date.now()}`,
body: (Array.isArray(existing.body) ? EMPTY_DOC : existing.body) as ArticleBodyDoc, title: '',
} date: new Date().toLocaleDateString('fa-IR'),
: { category: 'یادداشت',
id: 0, image: '',
slug: `article-${Date.now()}`, read: '۵ دقیقه',
title: '', excerpt: '',
date: new Date().toLocaleDateString('fa-IR'), body: EMPTY_DOC,
category: 'یادداشت', status: 'draft',
image: '', seo_keyphrase: '',
read: '۵ دقیقه', })
excerpt: '', const [createdId, setCreatedId] = useState<number | null>(null)
body: EMPTY_DOC,
},
)
const [createdId, setCreatedId] = useState<number | null>(existing?.id ?? null)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
// کلمه کلیدی هدف فقط راهنمای سئو است — در localStorage می‌ماند، نه در دیتابیس
const [keyphrase, setKeyphrase] = useState(() => localStorage.getItem(`seo-keyphrase-${id ?? 'new'}`) ?? '')
useEffect(() => {
localStorage.setItem(`seo-keyphrase-${id ?? 'new'}`, keyphrase)
}, [keyphrase, id])
const draftRef = useRef(draft) const draftRef = useRef(draft)
draftRef.current = draft draftRef.current = draft
const createdRef = useRef(createdId) const createdRef = useRef(createdId)
@@ -76,7 +68,35 @@ export function ArticleWritePage() {
const pendingRef = useRef<Record<string, unknown> | null>(null) const pendingRef = useRef<Record<string, unknown> | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
if (!isAuthenticated) return <Navigate to="/admin/login" replace /> // اگر مقاله در داده سایت نبود (مثلاً پیش از ادغام پیش‌نویس‌ها) مستقیم از انتهای مدیریتی خوانده می‌شود
useEffect(() => {
if (!id || existing) return
let cancelled = false
adminApi
.get<Draft>('articles', Number(id))
.then((a) => {
if (!cancelled) setFetched(a)
})
.catch(() => {
/* مقاله وجود ندارد — فرم «مقاله جدید» می‌ماند */
})
return () => {
cancelled = true
}
}, [id, existing])
const article = existing ?? fetched
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود
useEffect(() => {
if (!article || createdRef.current) return
setDraft({
...article,
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
})
setCreatedId(article.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [article?.id])
const doSave = async (changes: Record<string, unknown>) => { const doSave = async (changes: Record<string, unknown>) => {
if (createdRef.current) { if (createdRef.current) {
@@ -86,7 +106,7 @@ export function ArticleWritePage() {
if (a) Object.assign(a, changes) if (a) Object.assign(a, changes)
}) })
} else { } else {
// اولین ذخیره = انتشار: مقاله با همه فیلدهای فعلی ساخته می‌شود // اولین ذخیره = ساخت پیش‌نویس: مقاله ساخته می‌شود اما تا «انتشار» در سایت عمومی دیده نمی‌شود
const payload = { ...draftRef.current, ...changes } const payload = { ...draftRef.current, ...changes }
if (!payload.title) payload.title = 'بدون عنوان' if (!payload.title) payload.title = 'بدون عنوان'
const created = await adminApi.create<Draft & { id: number }>('articles', payload) const created = await adminApi.create<Draft & { id: number }>('articles', payload)
@@ -133,6 +153,9 @@ export function ArticleWritePage() {
const forceSave = () => void runSave(flush) const forceSave = () => void runSave(flush)
// محافظ مسیر — پس از همه هوک‌ها تا ترتیب فراخوانی هوک‌ها همیشه یکسان بماند
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
const remove = async () => { const remove = async () => {
if (!createdId) return if (!createdId) return
const result = await Swal.fire({ const result = await Swal.fire({
@@ -193,8 +216,28 @@ export function ArticleWritePage() {
> >
ذخیره ذخیره
</button> </button>
<span
className={`border px-3 py-2 text-xs font-light ${
draft.status === 'draft'
? 'border-amber-400/50 text-amber-300'
: 'border-emerald-400/50 text-emerald-300'
}`}
>
{draft.status === 'draft' ? 'پیش‌نویس' : 'منتشر شده'}
</span>
{createdId && ( {createdId && (
<> <>
<button
type="button"
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
draft.status === 'draft'
? 'bg-copper text-char hover:scale-105'
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
}`}
>
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'}
</button>
<Link <Link
to={`/blog/${draft.slug}`} to={`/blog/${draft.slug}`}
data-admin-link data-admin-link
@@ -220,7 +263,11 @@ export function ArticleWritePage() {
<main className="mx-auto max-w-4xl px-6 pb-32 pt-10"> <main className="mx-auto max-w-4xl px-6 pb-32 pt-10">
<p className="text-xs font-light text-copper"> <p className="text-xs font-light text-copper">
{createdId ? 'ویرایش مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود' : 'نوشتن مقاله جدید — با اولین ذخیره منتشر می‌شود'} {!createdId
? 'نوشتن مقاله جدید — به‌صورت پیش‌نویس ذخیره می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود'
: draft.status === 'draft'
? 'پیش‌نویس — تا انتشار در سایت عمومی دیده نمی‌شود؛ تغییرها ذخیره می‌شوند'
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}
</p> </p>
{/* عنوان */} {/* عنوان */}
@@ -260,7 +307,7 @@ export function ArticleWritePage() {
slug: draft.slug, slug: draft.slug,
excerpt: draft.excerpt, excerpt: draft.excerpt,
body: draft.body, body: draft.body,
keyphrase, keyphrase: draft.seo_keyphrase,
}} }}
/> />
@@ -272,8 +319,8 @@ export function ArticleWritePage() {
<input <input
id="seo-keyphrase" id="seo-keyphrase"
type="text" type="text"
value={keyphrase} value={draft.seo_keyphrase}
onChange={(e) => setKeyphrase(e.target.value)} onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none" className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/> />
</div> </div>
+41 -11
View File
@@ -223,38 +223,68 @@ export function AddItemButton({
) )
} }
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش */ /** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش.
* اگر رکورد هنوز در دیتابیس ساخته نشده باشد و createDefaults داده شود، دکمه + آن را می‌سازد. */
export function SingletonPill({ export function SingletonPill({
resource, resource,
label, label,
className = '', className = '',
select, select,
createDefaults,
}: { }: {
resource: string resource: string
label: string label: string
className?: string className?: string
/** انتخاب رکورد از درخت داده — برای مجموعه‌هایی که یک رکورد خاص اینجاست (مثل سئو) */ /** انتخاب رکورد از درخت داده — برای مجموعه‌هایی که یک رکورد خاص اینجاست (مثل سئو) */
select?: (data: NonNullable<ReturnType<typeof useSiteData>['data']>) => ItemWithId | undefined select?: (data: NonNullable<ReturnType<typeof useSiteData>['data']>) => ItemWithId | undefined
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
}) { }) {
const { isAuthenticated, isEditMode } = useAdmin() const { isAuthenticated, isEditMode, runSave } = useAdmin()
const config = ENTITIES[resource] const config = ENTITIES[resource]
const { data } = useSiteData() const { data, updateSite } = useSiteData()
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [created, setCreated] = useState<ItemWithId | null>(null)
if (!config || !isAuthenticated || !isEditMode) return null if (!config || !isAuthenticated || !isEditMode) return null
const item = select const item =
? select(data!) created ??
: (data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined) (select
if (!item) return null ? select(data!)
: (data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined))
if (!item && !createDefaults) return null
const create = () => {
const defaults =
typeof createDefaults === 'function' ? createDefaults() : (createDefaults ?? {})
void runSave(async () => {
const rec = await adminApi.create<ItemWithId>(config.resource, defaults)
// درج خوش‌بینانه در درخت داده — دفعه بعد از داده انتخاب می‌شود
updateSite((d) => {
const list = d[config.dataKey as keyof NonNullable<typeof d>] as unknown as ItemWithId[]
if (Array.isArray(list)) list.push(rec)
})
setCreated(rec)
setDrawerOpen(true)
})
}
return ( return (
<> <>
<span className={className}> <span className={className}>
<PillButton onClick={() => setDrawerOpen(true)} label={label}> {item ? (
<PencilIcon className="h-3.5 w-3.5" /> <PillButton onClick={() => setDrawerOpen(true)} label={label}>
</PillButton> <PencilIcon className="h-3.5 w-3.5" />
</PillButton>
) : (
<PillButton onClick={create} label={`ساخت — ${label}`}>
<PlusIcon className="h-3.5 w-3.5" />
</PillButton>
)}
</span> </span>
{drawerOpen && <EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />} {drawerOpen && item && (
<EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />
)}
</> </>
) )
} }
+11
View File
@@ -69,6 +69,11 @@ export const adminApi = {
patch: <T = unknown>(resource: string, id: number, partial: Record<string, unknown>) => patch: <T = unknown>(resource: string, id: number, partial: Record<string, unknown>) =>
authFetch<T>(`/api/admin/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(partial) }), authFetch<T>(`/api/admin/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(partial) }),
list: <T = unknown>(resource: string) => authFetch<T>(`/api/admin/${resource}`),
get: <T = unknown>(resource: string, id: number) =>
authFetch<T>(`/api/admin/${resource}/${id}`),
create: <T = unknown>(resource: string, data: Record<string, unknown>) => create: <T = unknown>(resource: string, data: Record<string, unknown>) =>
authFetch<T>(`/api/admin/${resource}`, { method: 'POST', body: JSON.stringify(data) }), authFetch<T>(`/api/admin/${resource}`, { method: 'POST', body: JSON.stringify(data) }),
@@ -83,4 +88,10 @@ export const adminApi = {
fd.append('file', file) fd.append('file', file)
return authFetch('/api/admin/upload', { method: 'POST', body: fd }) return authFetch('/api/admin/upload', { method: 'POST', body: fd })
}, },
changePassword: (currentPassword: string, newPassword: string) =>
authFetch<{ ok: boolean }>('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}),
} }
+20 -1
View File
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { Article, Project, Service } from './data/site' import type { Article, Project, Service } from './data/site'
import { API_URL } from './config' import { API_URL } from './config'
import { getToken } from './admin/adminClient'
// انواع داده‌هایی که بک‌اند در GET /api/site برمی‌گرداند // انواع داده‌هایی که بک‌اند در GET /api/site برمی‌گرداند
export type BrandInfo = { export type BrandInfo = {
@@ -183,7 +184,25 @@ export function SiteProvider({ children }: { children: ReactNode }) {
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<SiteData> return res.json() as Promise<SiteData>
}) })
.then((json) => { .then(async (json) => {
if (cancelled) return
// برای ادمین واردشده، پیش‌نویس مقاله‌ها از انتهای مدیریتی خوانده و ادغام می‌شوند —
// بک‌اند در GET /api/site فقط مقاله‌های منتشرشده را برمی‌گرداند
if (getToken()) {
try {
const res = await fetch(`${API_URL}/api/admin/articles`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (res.ok) {
const all = (await res.json()) as Article[]
const published = new Set(json.articles.map((a) => a.id))
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
json.articles = [...json.articles, ...drafts]
}
} catch {
/* بدون پیش‌نویس‌ها ادامه بده */
}
}
if (!cancelled) { if (!cancelled) {
if (json.brand && json.hero && json.footer && json.nav) setData(json) if (json.brand && json.hero && json.footer && json.nav) setData(json)
else setError(true) else setError(true)
+2
View File
@@ -78,6 +78,8 @@ export function PageHero({ pageKey }: PageHeroProps) {
resource="seo-meta" resource="seo-meta"
label="عنوان و توضیح سئوی این صفحه" label="عنوان و توضیح سئوی این صفحه"
select={(d) => d.seo_meta.find((s) => s.page_key === pageKey)} select={(d) => d.seo_meta.find((s) => s.page_key === pageKey)}
// اگر ردیف سئوی این صفحه هنوز ساخته نشده باشد، همین‌جا ساخته می‌شود
createDefaults={() => ({ page_key: pageKey, title: '', description: '' })}
/> />
<ImageField label="تصویر پس‌زمینه" value={hero.image} onSave={(url) => saveField('image', url)} /> <ImageField label="تصویر پس‌زمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
</div> </div>
+4 -1
View File
@@ -13,6 +13,9 @@ export function Articles() {
const heading = useSectionHeading('articles'); const heading = useSectionHeading('articles');
const save = useFieldSaver(); const save = useFieldSaver();
// پیش‌نویس‌ها فقط در صفحه وبلاگ (برای ادمین) دیده می‌شوند، نه در خانه
const published = articles.filter((a) => a.status !== 'draft');
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) => const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
save('section-headings', heading.id, { [field]: value }, (d) => { save('section-headings', heading.id, { [field]: value }, (d) => {
const h = d.section_headings.find((s) => s.id === heading.id); const h = d.section_headings.find((s) => s.id === heading.id);
@@ -36,7 +39,7 @@ export function Articles() {
</Reveal> </Reveal>
<div className="mt-14 grid gap-6 lg:grid-cols-3"> <div className="mt-14 grid gap-6 lg:grid-cols-3">
{articles.slice(0, 3).map((article, i) => {published.slice(0, 3).map((article, i) =>
<Reveal key={article.id} delay={0.06 * i} className="h-full"> <Reveal key={article.id} delay={0.06 * i} className="h-full">
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}> <EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
<ArticleCard article={article} /> <ArticleCard article={article} />
+6
View File
@@ -29,9 +29,15 @@ export type Project = {
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */ /** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
export type ArticleBodyDoc = { type: 'doc'; content: unknown[] }; export type ArticleBodyDoc = { type: 'doc'; content: unknown[] };
export type ArticleStatus = 'draft' | 'published';
export type Article = { export type Article = {
id: number; id: number;
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string; slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string;
/** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */ /** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */
body: ArticleBodyDoc | string[]; body: ArticleBodyDoc | string[];
/** پیش‌نویس فقط برای ادمین (از انتهای مدیریتی) می‌آید؛ بازدیدکننده نمی‌بیند */
status: ArticleStatus;
/** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */
seo_keyphrase: string;
}; };
-14
View File
@@ -1,14 +0,0 @@
{
"name": "magic-patterns-project",
"private": true,
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.30.2",
"lucide-react": "0.577.0",
"framer-motion": "11.18.2",
"@radix-ui/react-icons": "1.3.2",
"date-fns": "4.1.0",
"tailwind-merge": "2.6.1"
}
}
+21 -5
View File
@@ -7,7 +7,7 @@ import { ButtonLink } from '../components/Button';
import { ArticleCard } from '../components/ArticleCard'; import { ArticleCard } from '../components/ArticleCard';
import { Newsletter } from '../components/home/Newsletter'; import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api'; import { useSite } from '../api';
import { setMetaDescription } from '../seo'; import { applySeo } from '../seo';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
import type { Article } from '../data/site'; import type { Article } from '../data/site';
@@ -34,11 +34,27 @@ export function ArticleDetail() {
const { articles, brand } = useSite(); const { articles, brand } = useSite();
const article = articles.find((a) => a.slug === slug); const article = articles.find((a) => a.slug === slug);
// عنوان تب و توضیح متا از خود مقاله // عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
useEffect(() => { useEffect(() => {
if (!article) return; if (!article) return;
document.title = `${article.title} | ${brand}`; applySeo({
setMetaDescription(article.excerpt); title: `${article.title} | ${brand}`,
description: article.excerpt,
path: `/blog/${article.slug}`,
image: article.image,
type: 'article',
jsonLd: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.excerpt,
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
inLanguage: 'fa',
author: { '@type': 'Organization', name: brand },
publisher: { '@type': 'Organization', name: brand },
mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href,
},
});
}, [article?.id]); }, [article?.id]);
if (!article) { if (!article) {
@@ -55,7 +71,7 @@ export function ArticleDetail() {
} }
const { html, legacy } = renderBody(article); const { html, legacy } = renderBody(article);
const related = articles.filter((a) => a.slug !== article.slug).slice(0, 3); const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
return ( return (
<> <>
+51 -4
View File
@@ -9,9 +9,46 @@ import { useSite } from '../api'
import { EditableItem } from '../admin/Editable' import { EditableItem } from '../admin/Editable'
import { PlusIcon } from 'lucide-react' import { PlusIcon } from 'lucide-react'
function DraftBadge({ className = '' }: { className?: string }) {
return (
<span
className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`}
>
پیش‌نویس
</span>
)
}
export function Blog() { export function Blog() {
const { articles } = useSite() const { articles } = useSite()
const [featured, ...rest] = articles
// حالت خالی — وبلاگ بدون مقاله نباید کرش کند
if (articles.length === 0) {
return (
<>
<PageHero pageKey="blog" />
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
<div className="mx-auto max-w-site px-6 text-center lg:px-10">
<h2 className="text-3xl font-bold text-ivory">هنوز مقاله‌ای منتشر نشده است</h2>
<p className="mt-4 text-sm font-light text-ivory/60">
به‌زودی نخستین نوشته‌های ما اینجا منتشر می‌شود.
</p>
<Link
to="/admin/write"
data-admin-link
className="mt-8 inline-flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
>
<PlusIcon className="h-4 w-4" />
نوشتن مقاله جدید
</Link>
</div>
</section>
<Newsletter />
</>
)
}
const [featured, ...rest] = articles
return ( return (
<> <>
@@ -44,7 +81,12 @@ export function Blog() {
/> />
</div> </div>
<div className="flex flex-col justify-center p-8 lg:p-12"> <div className="flex flex-col justify-center p-8 lg:p-12">
<p className="text-xs font-light text-copper">مقاله ویژه — {featured.category}</p> <p className="text-xs font-light text-copper">
مقاله ویژه — {featured.category}
{featured.status === 'draft' && (
<DraftBadge className="mr-3 inline-block align-middle" />
)}
</p>
<h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl"> <h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl">
{featured.title} {featured.title}
</h2> </h2>
@@ -71,7 +113,12 @@ export function Blog() {
{rest.map((article, i) => ( {rest.map((article, i) => (
<Reveal key={article.id} delay={0.05 * i} className="h-full"> <Reveal key={article.id} delay={0.05 * i} className="h-full">
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}> <EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
<ArticleCard article={article} /> <div className="relative h-full">
{article.status === 'draft' && (
<DraftBadge className="absolute left-3 top-3 z-10" />
)}
<ArticleCard article={article} />
</div>
</EditableItem> </EditableItem>
</Reveal> </Reveal>
))} ))}
@@ -82,4 +129,4 @@ export function Blog() {
<Newsletter /> <Newsletter />
</> </>
) )
} }
+8 -4
View File
@@ -8,7 +8,7 @@ import { ContactSection } from '../components/ContactSection'
import { Lightbox } from '../components/Lightbox' import { Lightbox } from '../components/Lightbox'
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useSite, useStatusLabel } from '../api'; import { useSite, useStatusLabel } from '../api';
import { setMetaDescription } from '../seo'; import { applySeo } from '../seo';
export function ProjectDetail() { export function ProjectDetail() {
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
@@ -17,11 +17,15 @@ export function ProjectDetail() {
const statusLabel = useStatusLabel(project?.status ?? '') const statusLabel = useStatusLabel(project?.status ?? '')
const [lightbox, setLightbox] = useState<number | null>(null) const [lightbox, setLightbox] = useState<number | null>(null)
// عنوان تب و توضیح متا از خود پروژه // عنوان تب، توضیح متا و OG از خود پروژه
useEffect(() => { useEffect(() => {
if (!project) return; if (!project) return;
document.title = `${project.title} | ${brand}`; applySeo({
setMetaDescription(project.summary); title: `${project.title} | ${brand}`,
description: project.summary,
path: `/projects/${project.slug}`,
image: project.image,
});
}, [project?.id]); }, [project?.id]);
if (!project) { if (!project) {
+75 -5
View File
@@ -1,11 +1,11 @@
// تنظیم عنوان صفحه و توضیح متا — محتوای سئو از دیتابیس می‌آید // تگ‌های سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical و JSON-LD.
// محتوا از دیتابیس می‌آید؛ برای خزنده‌های بدون جاوااسکریپت، بک‌اند همان تگ‌ها را
// سمت سرور داخل index.html تزریق می‌کند (app/seo_pages.py).
export function setMetaDescription(content: string): void { export function setMetaDescription(content: string): void {
// بدون توضیح از دیتابیس، پیش‌فرض استاتیک index.html دست‌نخورده می‌ماند
if (!content.trim()) return
let el = document.querySelector<HTMLMetaElement>('meta[name="description"]') let el = document.querySelector<HTMLMetaElement>('meta[name="description"]')
if (!content.trim()) {
el?.remove()
return
}
if (!el) { if (!el) {
el = document.createElement('meta') el = document.createElement('meta')
el.setAttribute('name', 'description') el.setAttribute('name', 'description')
@@ -22,3 +22,73 @@ export function seoKeyForPath(pathname: string): string | null {
// مسیرهای جزئی مثل /blog/{slug} شامل نمی‌شوند تا اثر خود صفحه جزئیات برسانند // مسیرهای جزئی مثل /blog/{slug} شامل نمی‌شوند تا اثر خود صفحه جزئیات برسانند
return known.includes(seg) && pathname === `/${seg}` ? seg : null return known.includes(seg) && pathname === `/${seg}` ? seg : null
} }
function upsertMeta(attr: 'name' | 'property', key: string, content: string): void {
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`)
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, key)
document.head.appendChild(el)
}
el.setAttribute('content', content)
}
function upsertCanonical(path: string): void {
let el = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
if (!el) {
el = document.createElement('link')
el.setAttribute('rel', 'canonical')
document.head.appendChild(el)
}
el.setAttribute('href', new URL(path, window.location.origin).href)
}
function setJsonLd(data: Record<string, unknown> | null): void {
const id = 'seo-jsonld'
let el = document.getElementById(id)
if (!data) {
el?.remove()
return
}
if (!el) {
el = document.createElement('script')
el.setAttribute('type', 'application/ld+json')
el.id = id
document.head.appendChild(el)
}
el.textContent = JSON.stringify(data)
}
export type SeoInput = {
title: string
description?: string
/** مسیر canonical — پیش‌فرض مسیر جاری مرورگر */
path?: string
/** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل می‌شود */
image?: string
type?: 'website' | 'article'
jsonLd?: Record<string, unknown> | null
}
/** تنظیم همه تگ‌های سئوی صفحه در یک فراخوانی */
export function applySeo(input: SeoInput): void {
const title = input.title.trim()
const description = (input.description ?? '').trim()
const path = input.path ?? window.location.pathname
const image = (input.image ?? '').trim()
document.title = title
setMetaDescription(description)
upsertCanonical(path)
upsertMeta('property', 'og:title', title)
upsertMeta('property', 'og:description', description)
upsertMeta('property', 'og:type', input.type ?? 'website')
upsertMeta('property', 'og:url', new URL(path, window.location.origin).href)
upsertMeta('name', 'twitter:card', 'summary_large_image')
if (image) {
upsertMeta('property', 'og:image', new URL(image, window.location.origin).href)
} else {
document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.remove()
}
setJsonLd(input.jsonLd ?? null)
}
+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] ✔ تمام شد"