diff --git a/backend/README.md b/backend/README.md
index 05538bc..7f6b098 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -86,6 +86,13 @@ Authorization: Bearer
> نام کاربری و رمز عبور پیشفرض از تنظیمات `.env` خوانده میشود — حتماً در محیط تولید تغییرشان دهید.
+تغییر رمز (با توکن):
+
+```
+POST /api/auth/change-password
+{ "current_password": "...", "new_password": "..." }
+```
+
## فهرست انتهاپوینتها
### عمومی (بدون نیاز به توکن)
@@ -97,8 +104,8 @@ Authorization: Bearer
| GET | `/api/services` | خدمات |
| GET | `/api/projects` | همه پروژهها |
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
-| GET | `/api/articles` | همه مقالات |
-| GET | `/api/articles/{slug}` | جزئیات یک مقاله |
+| GET | `/api/articles` | مقالههای منتشرشده (پیشنویس نمیآید) |
+| GET | `/api/articles/{slug}` | جزئیات یک مقاله منتشرشده |
| GET | `/api/testimonials` | نظرات مشتریان |
| GET | `/api/faqs` | سوالات متداول |
| GET | `/api/team` | اعضای تیم |
@@ -109,7 +116,11 @@ Authorization: Bearer
| GET | `/api/site` | **همه محتوا در یک درخواست** |
| POST | `/api/contact` | ثبت پیام تماس |
| 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)
diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py
index ab539c3..eedbea4 100644
--- a/backend/app/api/admin.py
+++ b/backend/app/api/admin.py
@@ -4,15 +4,17 @@ import uuid
from datetime import datetime, timezone
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.exc import IntegrityError
from sqlalchemy.orm import Session
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 ..database import get_db
+from ..rate_limit import clear as clear_rate_limit
+from ..rate_limit import ensure_allowed, record_failure
from ..schemas import (
ArticleIn,
ArticleOut,
@@ -23,6 +25,7 @@ from ..schemas import (
BrandIn,
BrandOut,
BrandPatch,
+ ChangePasswordIn,
ContactSectionIn,
ContactSectionOut,
ContactSectionPatch,
@@ -105,16 +108,33 @@ router = APIRouter(dependencies=[Depends(get_current_admin)])
@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()
if user is None or not verify_password(payload.password, user.password_hash):
+ record_failure(request)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="نام کاربری یا رمز عبور اشتباه است",
)
+ clear_rate_limit(request)
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:
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
حذف و ترتیببندی) روی روتر مدیریت."""
diff --git a/backend/app/api/public.py b/backend/app/api/public.py
index 3ff7e3f..25a312d 100644
--- a/backend/app/api/public.py
+++ b/backend/app/api/public.py
@@ -55,12 +55,21 @@ def get_project(slug: str, db: Session = Depends(get_db)):
@router.get("/articles", response_model=list[ArticleOut])
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)
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:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
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(),
"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(),
- "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(),
"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(),
diff --git a/backend/app/config.py b/backend/app/config.py
index e425adc..2d97aeb 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -23,9 +23,14 @@ class Settings(BaseSettings):
s3_access_key: str = "aramesh"
s3_secret_key: str = "aramesh-minio-secret"
s3_bucket: str = "aramesh-media"
- # پیشوند عمومی مسیر تصاویر — nginx آن را به باکت پروکسی میکند
+ # پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی میکند
media_public_base: str = "/media"
media_max_upload_mb: int = 10
+ # نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
+ site_url: str = ""
+ # مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
+ frontend_dist: str = "../frontend/dist"
+
settings = Settings()
diff --git a/backend/app/main.py b/backend/app/main.py
index e63b138..e04fea7 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -6,6 +6,7 @@ from .api import admin, forms, public
from .config import settings
from .database import Base, engine
from .migrations import run_all as run_migrations
+from .seo_pages import router as seo_router
Base.metadata.create_all(bind=engine)
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(admin.auth_router, prefix="/api", tags=["احراز هویت"])
app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"])
+# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
+app.include_router(seo_router, tags=["سئو"])
@app.get("/")
diff --git a/backend/app/migrations.py b/backend/app/migrations.py
index afcaf0c..3e4cd48 100644
--- a/backend/app/migrations.py
+++ b/backend/app/migrations.py
@@ -25,6 +25,13 @@ def run_ddl_migrations() -> None:
conn.execute(
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:
diff --git a/backend/app/models.py b/backend/app/models.py
index 743816d..f5929a7 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -78,6 +78,10 @@ class Article(Base):
read: Mapped[str] = mapped_column(String(32), default="")
excerpt: Mapped[str] = mapped_column(Text, default="")
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):
diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py
new file mode 100644
index 0000000..ec35dc3
--- /dev/null
+++ b/backend/app/rate_limit.py
@@ -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)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 97d09b3..0e5811e 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -59,6 +59,8 @@ class ArticleOut(ORMModel):
read: str
excerpt: str
body: dict
+ status: str
+ seo_keyphrase: str
class TestimonialOut(ORMModel):
@@ -292,6 +294,11 @@ class TokenOut(BaseModel):
token_type: str = "bearer"
+class ChangePasswordIn(BaseModel):
+ current_password: str
+ new_password: str = Field(min_length=8, max_length=128)
+
+
class BrandIn(BaseModel):
name: str = ""
logo: str = ""
@@ -346,6 +353,8 @@ class ArticleIn(BaseModel):
read: str = ""
excerpt: str = ""
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)
@@ -548,6 +557,8 @@ class ArticlePatch(BaseModel):
read: Optional[str] = None
excerpt: Optional[str] = None
body: Optional[dict] = None
+ status: Optional[Literal["draft", "published"]] = None
+ seo_keyphrase: Optional[str] = None
_validate_body = field_validator("body")(validate_doc_body)
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
FaqPatch = _make_patch("FaqPatch", FaqIn)
diff --git a/backend/app/seo_pages.py b/backend/app/seo_pages.py
new file mode 100644
index 0000000..6df8cea
--- /dev/null
+++ b/backend/app/seo_pages.py
@@ -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'',
+ f'',
+ f'',
+ f'',
+ f'',
+ f'',
+ ]
+ if image:
+ tags.append(f'')
+ if jsonld:
+ payload = json.dumps(jsonld, ensure_ascii=False).replace("", "<\\/")
+ tags.append(f'')
+ block = "\n ".join(tags)
+
+ raw = _read_index()
+ if raw is None:
+ # بیلد فرانتاند سوار نشده — صفحه حداقلی با خودِ متا تا خزندهها دست خالی نمانند
+ body = f"{esc(title)}
{esc(description)}
"
+ return HTMLResponse(
+ f''
+ f"{esc(title)}{block}{body}"
+ )
+
+ raw = re.sub(r".*?", f"{esc(title)}", raw, count=1, flags=re.S)
+ raw = raw.replace("", f" {block}\n ", 1)
+ if noscript:
+ raw = raw.replace("
"
+ 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",
+ },
+ )
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
new file mode 100644
index 0000000..80e9d4a
--- /dev/null
+++ b/backend/requirements-dev.txt
@@ -0,0 +1,4 @@
+# وابستگیهای توسعه و تست — pip install -r requirements-dev.txt
+-r requirements.txt
+pytest==8.3.4
+httpx==0.27.2
diff --git a/backend/requirements.txt b/backend/requirements.txt
index b941c1b..bc23690 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,11 +1,13 @@
-fastapi>=0.115.8
-uvicorn[standard]>=0.34.0
-sqlalchemy>=2.0.36
-psycopg[binary]>=3.2.3
-pydantic>=2.10.4
-pydantic-settings>=2.6.1
-PyJWT>=2.10.1
-bcrypt>=4.2.1
-email-validator>=2.2.0
-boto3>=1.35.0
-python-multipart>=0.0.18
+# نسخهها دقیق پین شدهاند تا ارتقای خودکار یک وابستگی، استقرار را نشکند.
+# ارتقا: نسخه را دستی بالا ببرید و روی محیط آمادهسازی تست کنید.
+fastapi==0.115.8
+uvicorn[standard]==0.34.0
+sqlalchemy==2.0.36
+psycopg[binary]==3.2.3
+pydantic==2.10.4
+pydantic-settings==2.6.1
+PyJWT==2.10.1
+bcrypt==4.2.1
+email-validator==2.2.0
+boto3==1.35.0
+python-multipart==0.0.18
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000..7277948
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -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(
+ "