پیش‌نمایش خصوصی: سایت آنلاین اما غیرعمومی تا زمان انتشار

- گیت ورود Traefik (Basic Auth) روی همه مسیرها: سایت، API، تصاویر و چت — بدون رمز فقط ۴۰۱
- لایه دوم noindex: هدر X-Robots-Tag روی همه پاسخ‌ها + robots.txt مسدودکننده + نقشه سایت خالی
- اسکریپت scripts/preview-mode.sh on|off|status — ساخت خودکار نام کاربری/رمز و هماهنگ‌کردن .env
- دو دامنه (DOMAIN + DOMAIN_ALIAS) با گواهی TLS و ریدایرکت ۳۰۱ دامنه دوم به اصلی در زمان انتشار
- اسکیمای FAQPage در پوسته سئوی سمت سرور (فارسی و انگلیسی) برای خزنده‌های بدون جاوااسکریپت
- مستندسازی جریان پیش‌نمایش و چک‌لیست انتشار در README و .env.example
This commit is contained in:
MMD
2026-09-26 22:47:04 +03:30
parent 1d29e50852
commit d25db9367c
8 changed files with 485 additions and 9 deletions
+4
View File
@@ -32,6 +32,10 @@ class Settings(BaseSettings):
site_url: str = ""
# مسیر بیلد فرانت‌اند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
frontend_dist: str = "../frontend/dist"
# پیش‌نمایش خصوصی: سایت روی اینترنت است اما نه عمومی و نه ایندکس‌شدنی.
# گیت اصلی ورود در Traefik است (Basic Auth)؛ این پرچم لایه دوم است:
# هدر noindex روی همه پاسخ‌ها + robots.txt مسدودکننده.
preview_mode: bool = False
settings = Settings()
+11
View File
@@ -34,6 +34,17 @@ app.add_middleware(
# پاسخ‌های JSON /api — فشرده‌سازی وقتی بدنه قابل‌توجه است
app.add_middleware(GZipMiddleware, minimum_size=1024)
# پیش‌نمایش خصوصی — لایه دوم جلوگیری از ایندکس (لایه اول، گیت ورود Traefik است).
# روی همه پاسخ‌ها هدر noindex می‌نشیند تا حتی در صورت حذف گیت، گوگل چیزی ذخیره نکند.
# همیشه ثبت می‌شود و پرچم در زمان هر درخواست خوانده می‌شود (خاموش‌کردن انتشار = فقط تغییر .env).
@app.middleware("http")
async def _preview_noindex(request, call_next):
response = await call_next(request)
if settings.preview_mode:
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
return response
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=["احراز هویت"])
+45 -2
View File
@@ -188,6 +188,12 @@ def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]:
@router.get("/robots.txt", response_class=Response)
def robots(db: Session = Depends(get_db)):
# پیش‌نمایش خصوصی — هیچ خزنده‌ای نباید چیزی ذخیره کند
if settings.preview_mode:
return Response(
content="User-agent: *\nDisallow: /\n",
media_type="text/plain; charset=utf-8",
)
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
if settings.site_url:
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
@@ -206,6 +212,13 @@ def _sitemap_entry(loc: str, fa: str, en: str) -> str:
@router.get("/sitemap.xml", response_class=Response)
def sitemap(db: Session = Depends(get_db)):
# پیش‌نمایش خصوصی — نقشه سایت خالی تا هیچ نشانی‌ای اعلام نشود
if settings.preview_mode:
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n'
)
return Response(content=xml, media_type="application/xml; charset=utf-8")
fa_urls = list(STATIC_PATHS)
fa_urls += [
f"/blog/{a.slug}"
@@ -262,7 +275,7 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
brand = _brand_name(db)
title, excerpt = _article_pair(article, "fa")
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
jsonld = {
jsonld: object = {
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
@@ -273,6 +286,10 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
"publisher": {"@type": "Organization", "name": brand},
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
}
# پرسش‌وپاسخ مقاله → اسکیمای FAQPage در همان بلوک (آرایه‌ای از دو اسکیما)
faq_schema = _faq_jsonld(article, "fa")
if faq_schema:
jsonld = [jsonld, faq_schema]
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
return _shell(
title=f"{title} | {brand}",
@@ -325,12 +342,35 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
# ---------- صفحات انگلیسی (/en/...) ----------
def _faq_jsonld(article: models.Article, lang: str) -> dict | None:
"""اسکیمای FAQPage برای نتایج غنی گوگل — از پرسش‌وپاسخ‌های پایان مقاله.
نسخه انگلیسی فقط وقتی ترجمه ثبت شده باشد جایگزین می‌شود."""
faqs = article.faqs if isinstance(article.faqs, list) else []
if lang == "en":
faqs_en = article.faqs_en if isinstance(article.faqs_en, list) else None
if faqs_en:
faqs = faqs_en
entities = [
{"@type": "Question", "name": (f.get("question") or "").strip(),
"acceptedAnswer": {"@type": "Answer", "text": (f.get("answer") or "").strip()}}
for f in faqs
if isinstance(f, dict) and (f.get("question") or "").strip()
]
if not entities:
return None
return {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": entities,
}
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
brand = _brand_name(db, "en")
title, excerpt = _article_pair(article, "en")
body = article.body_en if isinstance(article.body_en, dict) else article.body
first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2])
jsonld = {
jsonld: object = {
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
@@ -341,6 +381,9 @@ def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
"publisher": {"@type": "Organization", "name": brand},
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
}
faq_schema = _faq_jsonld(article, "en")
if faq_schema:
jsonld = [jsonld, faq_schema]
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
return _shell(
title=f"{title} | {brand}",
+216
View File
@@ -1,6 +1,7 @@
"""تست‌های API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیش‌نویس مقاله و سئو."""
import re
import socket
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
@@ -361,3 +362,218 @@ def test_article_body_url_sanitization(auth_client):
body = res.json()["body"]
marks = body["content"][0]["content"][0].get("marks", [])
assert all("href" not in m.get("attrs", {}) for m in marks)
# ---------- نام‌گذاری تصاویر، درون‌ریزی و پرسش‌وپاسخ مقاله ----------
class _FakeS3:
def __init__(self, *args, **kwargs):
self.objects = {}
def put_object(self, **kwargs):
self.objects[kwargs["Key"]] = kwargs["Body"]
def _mock_storage(monkeypatch):
"""MinIO در تست در دسترس نیست — کلاینت S3 و DNS عمومی جعل می‌شوند."""
fake = _FakeS3()
monkeypatch.setattr("app.api.admin.boto3.client", lambda *a, **k: fake)
monkeypatch.setattr(
"app.api.admin.socket.getaddrinfo",
lambda host, port, *a, **k: [(socket.AF_INET, None, None, "", ("93.184.216.34", 0))],
)
return fake
def _png_bytes() -> bytes:
from PIL import Image
import io
buf = io.BytesIO()
Image.new("RGB", (8, 8), (200, 120, 60)).save(buf, "PNG")
return buf.getvalue()
def test_upload_with_readable_name(auth_client, monkeypatch):
_mock_storage(monkeypatch)
res = auth_client.post(
"/api/admin/upload",
files={"file": ("raw name.png", _png_bytes(), "image/png")},
data={"name": "برج الهیه tower!"},
)
assert res.status_code == 200, res.text
key = res.json()["key"]
# نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + پسوند تصویر
assert "برج-الهیه-tower" in key
assert key.endswith(".png")
assert "!" not in key
def test_upload_without_name_uses_filename(auth_client, monkeypatch):
_mock_storage(monkeypatch)
res = auth_client.post(
"/api/admin/upload",
files={"file": ("Elahieh Tower.jpg", _png_bytes(), "image/jpeg")},
data={"name": ""},
)
assert res.status_code == 200
assert "Elahieh-Tower" in res.json()["key"]
def test_import_image_rejects_internal_host(auth_client):
# SSRF — میزبان حلقه‌محلی/خصوصی رد می‌شود پیش از هر درخواست شبکه
res = auth_client.post("/api/admin/import-image", json={"url": "http://127.0.0.1:9000/x.png"})
assert res.status_code == 400
res = auth_client.post("/api/admin/import-image", json={"url": "http://192.168.1.5/x.png"})
assert res.status_code == 400
res = auth_client.post("/api/admin/import-image", json={"url": "file:///etc/passwd"})
assert res.status_code == 400
def test_import_image_rejects_bad_content_type(auth_client, monkeypatch):
_mock_storage(monkeypatch)
import io as _io
class FakeResponse:
def __init__(self):
self.headers = {"Content-Type": "text/html"}
def read(self, n):
return b"<html></html>"
def __enter__(self):
return self
def __exit__(self, *args):
return False
def fake_urlopen(request, timeout):
return FakeResponse()
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", fake_urlopen)
res = auth_client.post("/api/admin/import-image", json={"url": "https://example.com/page"})
assert res.status_code == 415
def test_import_image_happy_path(auth_client, monkeypatch):
_mock_storage(monkeypatch)
class FakeResponse:
def __init__(self):
self.headers = {"Content-Type": "image/png"}
def read(self, n):
return _png_bytes()
def __enter__(self):
return self
def __exit__(self, *args):
return False
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", lambda request, timeout: FakeResponse())
res = auth_client.post("/api/admin/import-image", json={"url": "https://wiki.example/Elahieh pic.png?token=1"})
assert res.status_code == 200, res.text
assert res.json()["url"].startswith("/media/")
assert "Elahieh-pic" in res.json()["key"]
def test_article_persists_image_meta_and_faqs(auth_client, client):
created = auth_client.post(
"/api/admin/articles",
json={"slug": "meta-article", "title": "متادیتا", "body": DOC},
)
assert created.status_code == 201, created.text
article_id = created.json()["id"]
faqs = [{"question": "هزینه چقدر است؟", "answer": "بستگی به پروژه دارد."}]
res = auth_client.patch(
f"/api/admin/articles/{article_id}",
json={
"image_alt": "نمای برج الهیه",
"image_title": "برج آرامش",
"faqs": faqs,
"faqs_en": [{"question": "What is the cost?", "answer": "Depends on the project."}],
},
)
assert res.status_code == 200, res.text
assert res.json()["image_alt"] == "نمای برج الهیه"
assert res.json()["image_title"] == "برج آرامش"
assert res.json()["faqs"] == faqs
# انتشار و سپس خروجی عمومی — همین مقادیر برمی‌گردد
assert auth_client.patch(f"/api/admin/articles/{article_id}", json={"status": "published"}).status_code == 200
public = client.get("/api/articles/meta-article").json()
assert public["image_alt"] == "نمای برج الهیه"
assert public["faqs"] == faqs
assert auth_client.delete(f"/api/admin/articles/{article_id}").status_code == 204
def test_blog_shell_includes_faqpage_jsonld(auth_client, client):
created = auth_client.post(
"/api/admin/articles",
json={
"slug": "faq-article",
"title": "مقاله پرسش‌وپاسخ",
"body": DOC,
"status": "published",
"faqs": [{"question": "زمان ساخت؟", "answer": "۳۰ ماه."}],
},
)
assert created.status_code == 201, created.text
res = client.get("/blog/faq-article")
assert res.status_code == 200
assert "FAQPage" in res.text
assert "زمان ساخت؟" in res.text
# نسخه انگلیسی بدون ترجمه → همان فارسی؛ با ترجمه → انگلیسی
en = client.get("/en/blog/faq-article").text
assert "FAQPage" in en
article_id = created.json()["id"]
auth_client.patch(
f"/api/admin/articles/{article_id}",
json={"faqs_en": [{"question": "How long?", "answer": "Thirty months."}]},
)
en = client.get("/en/blog/faq-article").text
assert "How long?" in en
assert "زمان ساخت؟" not in en
auth_client.delete(f"/api/admin/articles/{article_id}")
def test_blog_shell_without_faqs_has_no_faqpage(client):
res = client.get("/blog/published-post")
assert res.status_code == 200
assert "FAQPage" not in res.text
# ---------- پیش‌نمایش خصوصی (برای انتشار عمومی خاموش می‌شود) ----------
def test_preview_mode_blocks_robots_and_sitemap(client, monkeypatch):
from app.config import settings
monkeypatch.setattr(settings, "preview_mode", True)
try:
robots = client.get("/robots.txt")
assert robots.status_code == 200
assert "Disallow: /" in robots.text
assert "Sitemap:" not in robots.text
sitemap = client.get("/sitemap.xml")
assert sitemap.status_code == 200
assert "published-post" not in sitemap.text
# لایه دوم: هدر noindex روی همه پاسخ‌ها
assert "noindex" in client.get("/api/health").headers.get("X-Robots-Tag", "")
finally:
monkeypatch.setattr(settings, "preview_mode", False)
def test_public_mode_allows_indexing(client):
robots = client.get("/robots.txt").text
assert "Disallow: /admin" in robots
assert "Sitemap:" in robots
assert "noindex" not in client.get("/api/health").headers.get("X-Robots-Tag", "")