From d25db9367c3abee71a1916503345892dca914fdc Mon Sep 17 00:00:00 2001 From: MMD Date: Sat, 26 Sep 2026 22:47:04 +0330 Subject: [PATCH] =?UTF-8?q?=D9=BE=DB=8C=D8=B4=E2=80=8C=D9=86=D9=85=D8=A7?= =?UTF-8?q?=DB=8C=D8=B4=20=D8=AE=D8=B5=D9=88=D8=B5=DB=8C:=20=D8=B3=D8=A7?= =?UTF-8?q?=DB=8C=D8=AA=20=D8=A2=D9=86=D9=84=D8=A7=DB=8C=D9=86=20=D8=A7?= =?UTF-8?q?=D9=85=D8=A7=20=D8=BA=DB=8C=D8=B1=D8=B9=D9=85=D9=88=D9=85=DB=8C?= =?UTF-8?q?=20=D8=AA=D8=A7=20=D8=B2=D9=85=D8=A7=D9=86=20=D8=A7=D9=86=D8=AA?= =?UTF-8?q?=D8=B4=D8=A7=D8=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - گیت ورود 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 --- .env.example | 14 +++ README.md | 39 ++++++- backend/app/config.py | 4 + backend/app/main.py | 11 ++ backend/app/seo_pages.py | 47 ++++++++- backend/tests/test_api.py | 216 ++++++++++++++++++++++++++++++++++++++ docker-compose.prod.yml | 38 ++++++- scripts/preview-mode.sh | 125 ++++++++++++++++++++++ 8 files changed, 485 insertions(+), 9 deletions(-) create mode 100644 scripts/preview-mode.sh diff --git a/.env.example b/.env.example index 65efee3..bac603a 100644 --- a/.env.example +++ b/.env.example @@ -55,3 +55,17 @@ OFFSITE_RCLONE_REMOTE= # --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) --- # DOMAIN=aramesh.example # ACME_EMAIL=admin@aramesh.example +# دامنه دوم (در صورت وجود) — مسیرها و گواهی TLS برای هر دو صادر می‌شود +# DOMAIN_ALIAS=aramesh.example.ir + +# --- پیش‌نمایش خصوصی و انتشار (scripts/preview-mode.sh) --- +# «on» یعنی سایت آنلاین است اما پشت گیت ورود و بدون ایندکس؛ «off» یعنی انتشار عمومی. +# این مقادیر را دستی ویرایش نکنید — اسکریپت preview-mode.sh هماهنگشان می‌کند: +# bash scripts/preview-mode.sh on|off|status → bash scripts/stack.sh deploy-prod +# PREVIEW_MODE=on +# هش htpasswd کاربر مشترک پیش‌نمایش — اولین «on» خودش می‌سازد +# PREVIEW_AUTH_USERS=preview:$apr1$... +# فهرست میان‌افزارهای فعال؛ «on» پرش می‌کند و «off» خالیش می‌کند +# PREVIEW_MIDDLEWARES=preview-auth@docker,preview-noindex@docker +# در انتشار، دامنه دوم را به اصلی ریدایرکت می‌کند تا محتوای تکراری ساخته نشود +# ALIAS_REDIRECT_MIDDLEWARES=to-primary@docker diff --git a/README.md b/README.md index 124fd85..0db3efa 100644 --- a/README.md +++ b/README.md @@ -103,12 +103,47 @@ bash scripts/stack.sh deploy-prod `ssh -L 9001:127.0.0.1:9001 server` - همه روترهای websecure هدرهای امنیتی می‌گیرند (HSTS + nosniff) +### پیش‌نمایش خصوصی (کار روی سایت، قبل از انتشار عمومی) + +سایت روی اینترنت بالا می‌آید و تیم با همان پنل مدیریت محتوا را ویرایش می‌کند +(ویرایش درجای ایمیل/تلفن/متن‌ها و نوشتن مقاله)، اما **نه عموم به آن دسترسی دارند و +نه گوگل چیزی ایندکس می‌کند**. دو لایه محافظت: + +1. **گیت ورود Traefik (Basic Auth)** — همه مسیرها (سایت، API، تصاویر و چت) پشت یک + نام کاربری/رمز مشترک‌اند؛ بدون آن فقط ۴۰۱ برمی‌گردد +2. **noindex** — هدر `X-Robots-Tag` روی همه پاسخ‌ها + `robots.txt` مسدودکننده + + نقشه سایت خالی؛ حتی اگر گیت برداشته شود، گوگل چیزی ذخیره نمی‌کند + +```bash +bash scripts/preview-mode.sh on # فعال‌سازی (بار اول نام کاربری/رمز می‌سازد و چاپ می‌کند) +bash scripts/stack.sh deploy-prod # اعمال +bash scripts/preview-mode.sh status # وضعیت فعلی +``` + +- دامنه اصلی (`DOMAIN`) و دامنه دوم (`DOMAIN_ALIAS`) هر دو سرو می‌شوند و هر دو گیت دارند +- برای دادن دسترسی به کارفرما، همان نام کاربری/رمز مشترک را بدهید +- رمز مشترک در `.env` (`PREVIEW_AUTH_USERS`) می‌ماند — فقط یک‌بار چاپ می‌شود + +### انتشار عمومی (برداشتن پیش‌نمایش) + +```bash +bash scripts/preview-mode.sh off # حذف گیت ورود + اجازه ایندکس +bash scripts/stack.sh deploy-prod +``` + +بعد از این مرحله: + +1. دامنه دوم به‌صورت ۳۰۱ دائمی به دامنه اصلی ریدایرکت می‌شود (جلوگیری از محتوای تکراری) +2. در Search Console دامنه را تأیید و `https://DOMAIN/sitemap.xml` را ثبت کنید +3. اگر پیش‌تر چیزی ایندکس شده بود، حذفش را از همان کنسول درخواست کنید + ### چک‌لیست رفتن به تولید 1. `bash scripts/gen-env.sh` — رمزهای قوی جدید (رمزهای `.env` توسعه را کپی نکنید) 2. DNS: رکوردهای دامنه و زیردامنه‌ها + باز بودن پورت‌های ۸۰/۴۴۳ در فایروال -3. `DOMAIN`، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید -4. اولین انتشار را روی یک زیردامنه آزمایشی انجام دهید (نقش staging) — `deploy-prod` +3. `DOMAIN`، `DOMAIN_ALIAS` (دامنه دوم)، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید +4. `bash scripts/preview-mode.sh on` — اولین انتشار به‌صورت پیش‌نمایش خصوصی می‌رود؛ + محتوا را روی سایت آنلاین نهایی کنید و بعد `preview-mode.sh off` + deploy مجدد 5. `python scripts/migrate-media.py` — انتقال یک‌باره تصاویر CDN خارجی به MinIO 6. پشتیبان‌گیری خودکار: `docker compose --profile ops up -d db-backup` (پشتیبان ساعتی) + `scripts/backup.sh` شبانه با cron/Task Scheduler diff --git a/backend/app/config.py b/backend/app/config.py index c2285c9..2edf067 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index fa55667..1ee7237 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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=["احراز هویت"]) diff --git a/backend/app/seo_pages.py b/backend/app/seo_pages.py index 3ad0baf..0dfab46 100644 --- a/backend/app/seo_pages.py +++ b/backend/app/seo_pages.py @@ -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 = ( + '\n' + '\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"

{html.escape(title)}

{html.escape(first_text)}

" 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"

{html.escape(title)}

{html.escape(first_text)}

" return _shell( title=f"{title} | {brand}", diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index eabc012..28a0bb2 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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"" + + 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", "") diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f774939..3d73c1c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -6,6 +6,16 @@ # # همه درخواست‌های HTTP به HTTPS هدایت می‌شوند و مسیریابی‌ها فقط روی دامنه DOMAIN پاسخ می‌دهند. # برای داشبوردها: analytics.DOMAIN (آنالیتیکس) و grafana.DOMAIN (مانیتورینگ، با فایل monitoring). +# +# ---- پیش‌نمایش خصوصی و انتشار (scripts/preview-mode.sh) ---- +# در حالت پیش‌نمایش (PREVIEW_MODE=on) کل سایت پشت Basic Auth است و noindex می‌گیرد: +# تیم با یک نام کاربری/رمز مشترک وارد می‌شود، محتوا را ویرایش می‌کند و هیچ‌کس دیگری — +# از جمله گوگل — به سایت دسترسی ندارد. برای انتشار عمومی: +# bash scripts/preview-mode.sh off → deploy مجدد → ثبت sitemap در Search Console. +# +# DOMAIN دامنه اصلی است و DOMAIN_ALIAS دامنه دوم (هر دو مسیر و گواهی می‌گیرند). +# در زمان انتشار، برای جلوگیری از محتوای تکراری، دامنه دوم را به اصلی ریدایرکت کنید: +# ALIAS_REDIRECT=on (در .env) — ریدایرکت ۳۰۱ دائمی به دامنه اصلی. services: traefik: @@ -43,7 +53,7 @@ services: - traefik.http.routers.media.entrypoints=websecure - traefik.http.routers.media.tls.certresolver=le - traefik.http.routers.media.priority=100 - - traefik.http.routers.media.middlewares=media-rewrite,sec-headers + - traefik.http.routers.media.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}media-rewrite,sec-headers - traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*) - traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1 - traefik.http.services.media.loadbalancer.server.port=9000 @@ -53,16 +63,18 @@ services: # سایت هم‌مبدأ است — CORS لازم نیست CORS_ORIGINS: '[]' SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید} + # پیش‌نمایش خصوصی — هدر noindex + robots مسدودکننده (لایه دوم؛ لایه اول گیت Traefik) + PREVIEW_MODE: ${PREVIEW_MODE:-off} 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.api.middlewares=api-flood,sec-headers + - traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers - traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)) - traefik.http.routers.seo.entrypoints=websecure - traefik.http.routers.seo.tls.certresolver=le - - traefik.http.routers.seo.middlewares=sec-headers + - traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers frontend: labels: @@ -70,12 +82,28 @@ services: - traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`) - traefik.http.routers.web.entrypoints=websecure - traefik.http.routers.web.tls.certresolver=le - - traefik.http.routers.web.middlewares=sec-headers + - traefik.http.routers.web.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers # تعریف یک‌بارهٔ میان‌افزار هدرهای امنیتی — روی همهٔ روترهای websecure استفاده می‌شود - traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000 - traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true - traefik.http.middlewares.sec-headers.headers.stsPreload=true - traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true + # ---- گیت پیش‌نمایش خصوصی ---- + # ورود با نام کاربری/رمز مشترک (htpasswd) — تا وقتی PREVIEW_MIDDLEWARES تنظیم است فعال می‌شود + - traefik.http.middlewares.preview-auth.basicauth.users=${PREVIEW_AUTH_USERS:-disabled:nope} + - traefik.http.middlewares.preview-auth.basicauth.realm=Aramesh private preview + # لایه دوم جلوگیری از ایندکس — حتی اگر گیت حذف شد، گوگل چیزی ذخیره نکند + - traefik.http.middlewares.preview-noindex.headers.customresponseheaders.X-Robots-Tag=noindex, nofollow, noarchive + # ---- ریدایرکت دامنه دوم به اصلی (زمان انتشار: ALIAS_REDIRECT=on) ---- + - traefik.http.middlewares.to-primary.redirectregex.regex=^https?://[^/]+(.*) + - traefik.http.middlewares.to-primary.redirectregex.replacement=https://${DOMAIN}$$1 + - traefik.http.middlewares.to-primary.redirectregex.permanent=true + - traefik.http.routers.alias-redirect.rule=Host(`${DOMAIN_ALIAS:-invalid.invalid}`) + - traefik.http.routers.alias-redirect.entrypoints=websecure + - traefik.http.routers.alias-redirect.tls.certresolver=le + - traefik.http.routers.alias-redirect.priority=1000 + - traefik.http.routers.alias-redirect.middlewares=${ALIAS_REDIRECT_MIDDLEWARES:-sec-headers} + - traefik.http.routers.alias-redirect.service=web chatbot: labels: @@ -84,7 +112,7 @@ services: - traefik.http.routers.chat.entrypoints=websecure - traefik.http.routers.chat.tls.certresolver=le - traefik.http.routers.chat.priority=100 - - traefik.http.routers.chat.middlewares=api-flood,sec-headers + - traefik.http.routers.chat.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers umami: labels: diff --git a/scripts/preview-mode.sh b/scripts/preview-mode.sh new file mode 100644 index 0000000..18e09de --- /dev/null +++ b/scripts/preview-mode.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# پیش‌نمایش خصوصی و انتشار عمومی سایت. +# +# bash scripts/preview-mode.sh status # وضعیت فعلی +# bash scripts/preview-mode.sh on # سایت آنلاین ولی خصوصی (گیت ورود + noindex) +# bash scripts/preview-mode.sh off # انتشار عمومی (حذف گیت + اجازه ایندکس) +# +# در حالت «on» کل سایت پشت Basic Auth است: تیم با نام کاربری/رمز مشترک وارد می‌شود، +# محتوا را در همان سایت آنلاین ویرایش می‌کند (ویرایش درجای ایمیل/تلفن و بقیه محتوا)، +# اما نه عموم به سایت دسترسی دارند و نه گوگل چیزی ایندکس می‌کند. +# +# پس از هر تغییر، استک را دوباره بالا بیاورید: +# bash scripts/stack.sh deploy-prod +# +# با «فعال شدن» اولین بار، نام کاربری/رمز ساخته و در .env ذخیره می‌شود +# (پیش‌فرض: preview / یک رمز تصادفی — در خروجی چاپ می‌شود). + +set -euo pipefail +cd "$(dirname "$0")/.." + +ENV_FILE=.env +[ -f "$ENV_FILE" ] || { echo "فایل .env پیدا نشد — اول bash scripts/gen-env.sh" >&2; exit 1; } + +# مقدار فعلی یک کلید در .env (یا خالی) +env_get() { + local key="$1" + sed -n "s/^${key}=//p" "$ENV_FILE" | tail -1 +} + +# تنظیم/جایگزینی یک کلید در .env (بدون آسیب به بقیه خطوط) +env_set() { + local key="$1" value="$2" + if grep -q "^${key}=" "$ENV_FILE"; then + # جداکننده | تا کاراکترهای ویژه مقدار (مثل / و $) مشکلی نسازند + python - "$ENV_FILE" "$key" "$value" <<'PY' +import io, sys +path, key, value = sys.argv[1], sys.argv[2], sys.argv[3] +lines = io.open(path, encoding="utf-8").read().split("\n") +out = [f"{key}={value}" if line.startswith(f"{key}=") else line for line in lines] +io.open(path, "w", encoding="utf-8", newline="\n").write("\n".join(out)) +PY + else + printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE" + fi +} + +# ساخت هش htpasswd — اول با openssl، در نبودش با ایمیج httpd (Docker لازم است) +make_htpasswd() { + local user="$1" pass="$2" hash + if command -v openssl >/dev/null 2>&1; then + hash=$(openssl passwd -apr1 "$pass") + else + hash=$(docker run --rm httpd:2.4-alpine htpasswd -nbB "$user" "$pass" | cut -d: -f2) + fi + printf '%s:%s' "$user" "$hash" +} + +# رمز تصادفی خوانا (بدون نویسه‌های گیج‌کننده) +random_password() { + python - <<'PY' +import secrets, string +alphabet = string.ascii_letters + string.digits +print("".join(secrets.choice(alphabet) for _ in range(16))) +PY +} + +PREVIEW_MIDDLEWARES_VALUE="preview-auth@docker,preview-noindex@docker" + +case "${1:-status}" in + on) + users="$(env_get PREVIEW_AUTH_USERS)" + if [ -z "$users" ]; then + password="$(random_password)" + users="$(make_htpasswd preview "$password")" + env_set PREVIEW_AUTH_USERS "$users" + echo "نام کاربری/رمز پیش‌نمایش ساخته شد:" + echo " کاربر: preview" + echo " رمز: $password" + echo " (این رمز فقط همین یک‌بار چاپ می‌شود — در .env ذخیره شده است)" + echo "" + fi + env_set PREVIEW_MODE on + env_set PREVIEW_MIDDLEWARES "$PREVIEW_MIDDLEWARES_VALUE" + # در پیش‌نمایش، دامنه دوم به اصلی ریدایرکت نمی‌شود تا هر دو قابل بررسی باشند + env_set ALIAS_REDIRECT_MIDDLEWARES "" + echo "پیش‌نمایش خصوصی فعال شد — سایت آنلاین است، ورود با نام کاربری/رمز، بدون ایندکس." + echo "برای اعمال: bash scripts/stack.sh deploy-prod" + ;; + + off) + env_set PREVIEW_MODE off + env_set PREVIEW_MIDDLEWARES "" + # در انتشار، دامنه دوم به اصلی ریدایرکت می‌شود تا محتوای تکراری ایجاد نشود + env_set ALIAS_REDIRECT_MIDDLEWARES "to-primary@docker" + echo "حالت انتشار عمومی فعال شد — گیت ورود حذف و ایندکس مجاز می‌شود." + echo "پس از deploy، نقشه سایت را در Search Console ثبت کنید:" + echo " https://\$(grep '^DOMAIN=' .env | cut -d= -f2)/sitemap.xml" + echo "برای اعمال: bash scripts/stack.sh deploy-prod" + ;; + + status) + mode="$(env_get PREVIEW_MODE)" + mws="$(env_get PREVIEW_MIDDLEWARES)" + alias_mws="$(env_get ALIAS_REDIRECT_MIDDLEWARES)" + if [ "$mode" = "on" ]; then + echo "وضعیت: پیش‌نمایش خصوصی (آنلاین، غیرعمومی، noindex)" + elif [ "$mode" = "off" ]; then + echo "وضعیت: انتشار عمومی" + else + echo "وضعیت: تعیین‌نشده (PREVIEW_MODE=${mode:-خالی}) — پیش‌فرض compose: off" + fi + echo " PREVIEW_MIDDLEWARES=${mws:-(خالی — بدون گیت)}" + echo " ALIAS_REDIRECT_MIDDLEWARES=${alias_mws:-(خالی — بدون ریدایرکت دامنه دوم)}" + if [ -n "$(env_get PREVIEW_AUTH_USERS)" ]; then + echo " کاربر پیش‌نمایش: $(env_get PREVIEW_AUTH_USERS | cut -d: -f1)" + else + echo " کاربر پیش‌نمایش: ساخته نشده (اولین «on» می‌سازد)" + fi + ;; + + *) + echo "استفاده: bash scripts/preview-mode.sh [on|off|status]" >&2 + exit 1 + ;; +esac