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

- گیت ورود 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
+14
View File
@@ -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
+37 -2
View File
@@ -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
+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", "")
+33 -5
View File
@@ -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:
+125
View File
@@ -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