پیشنمایش خصوصی: سایت آنلاین اما غیرعمومی تا زمان انتشار
- گیت ورود 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:
@@ -55,3 +55,17 @@ OFFSITE_RCLONE_REMOTE=
|
|||||||
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
|
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
|
||||||
# DOMAIN=aramesh.example
|
# DOMAIN=aramesh.example
|
||||||
# ACME_EMAIL=admin@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
|
||||||
|
|||||||
@@ -103,12 +103,47 @@ bash scripts/stack.sh deploy-prod
|
|||||||
`ssh -L 9001:127.0.0.1:9001 server`
|
`ssh -L 9001:127.0.0.1:9001 server`
|
||||||
- همه روترهای websecure هدرهای امنیتی میگیرند (HSTS + nosniff)
|
- همه روترهای 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` توسعه را کپی نکنید)
|
1. `bash scripts/gen-env.sh` — رمزهای قوی جدید (رمزهای `.env` توسعه را کپی نکنید)
|
||||||
2. DNS: رکوردهای دامنه و زیردامنهها + باز بودن پورتهای ۸۰/۴۴۳ در فایروال
|
2. DNS: رکوردهای دامنه و زیردامنهها + باز بودن پورتهای ۸۰/۴۴۳ در فایروال
|
||||||
3. `DOMAIN`، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید
|
3. `DOMAIN`، `DOMAIN_ALIAS` (دامنه دوم)، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید
|
||||||
4. اولین انتشار را روی یک زیردامنه آزمایشی انجام دهید (نقش staging) — `deploy-prod`
|
4. `bash scripts/preview-mode.sh on` — اولین انتشار بهصورت پیشنمایش خصوصی میرود؛
|
||||||
|
محتوا را روی سایت آنلاین نهایی کنید و بعد `preview-mode.sh off` + deploy مجدد
|
||||||
5. `python scripts/migrate-media.py` — انتقال یکباره تصاویر CDN خارجی به MinIO
|
5. `python scripts/migrate-media.py` — انتقال یکباره تصاویر CDN خارجی به MinIO
|
||||||
6. پشتیبانگیری خودکار: `docker compose --profile ops up -d db-backup` (پشتیبان ساعتی)
|
6. پشتیبانگیری خودکار: `docker compose --profile ops up -d db-backup` (پشتیبان ساعتی)
|
||||||
+ `scripts/backup.sh` شبانه با cron/Task Scheduler
|
+ `scripts/backup.sh` شبانه با cron/Task Scheduler
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ class Settings(BaseSettings):
|
|||||||
site_url: str = ""
|
site_url: str = ""
|
||||||
# مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
|
# مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
|
||||||
frontend_dist: str = "../frontend/dist"
|
frontend_dist: str = "../frontend/dist"
|
||||||
|
# پیشنمایش خصوصی: سایت روی اینترنت است اما نه عمومی و نه ایندکسشدنی.
|
||||||
|
# گیت اصلی ورود در Traefik است (Basic Auth)؛ این پرچم لایه دوم است:
|
||||||
|
# هدر noindex روی همه پاسخها + robots.txt مسدودکننده.
|
||||||
|
preview_mode: bool = False
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ app.add_middleware(
|
|||||||
# پاسخهای JSON /api — فشردهسازی وقتی بدنه قابلتوجه است
|
# پاسخهای JSON /api — فشردهسازی وقتی بدنه قابلتوجه است
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
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(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=["احراز هویت"])
|
||||||
|
|||||||
@@ -188,6 +188,12 @@ def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
@router.get("/robots.txt", response_class=Response)
|
@router.get("/robots.txt", response_class=Response)
|
||||||
def robots(db: Session = Depends(get_db)):
|
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", ""]
|
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
|
||||||
if settings.site_url:
|
if settings.site_url:
|
||||||
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
|
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)
|
@router.get("/sitemap.xml", response_class=Response)
|
||||||
def sitemap(db: Session = Depends(get_db)):
|
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 = list(STATIC_PATHS)
|
||||||
fa_urls += [
|
fa_urls += [
|
||||||
f"/blog/{a.slug}"
|
f"/blog/{a.slug}"
|
||||||
@@ -262,7 +275,7 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
brand = _brand_name(db)
|
brand = _brand_name(db)
|
||||||
title, excerpt = _article_pair(article, "fa")
|
title, excerpt = _article_pair(article, "fa")
|
||||||
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
||||||
jsonld = {
|
jsonld: object = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
"headline": title,
|
"headline": title,
|
||||||
@@ -273,6 +286,10 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
"publisher": {"@type": "Organization", "name": brand},
|
"publisher": {"@type": "Organization", "name": brand},
|
||||||
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
"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>"
|
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||||
return _shell(
|
return _shell(
|
||||||
title=f"{title} | {brand}",
|
title=f"{title} | {brand}",
|
||||||
@@ -325,12 +342,35 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
# ---------- صفحات انگلیسی (/en/...) ----------
|
# ---------- صفحات انگلیسی (/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:
|
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
||||||
brand = _brand_name(db, "en")
|
brand = _brand_name(db, "en")
|
||||||
title, excerpt = _article_pair(article, "en")
|
title, excerpt = _article_pair(article, "en")
|
||||||
body = article.body_en if isinstance(article.body_en, dict) else article.body
|
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])
|
first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2])
|
||||||
jsonld = {
|
jsonld: object = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
"headline": title,
|
"headline": title,
|
||||||
@@ -341,6 +381,9 @@ def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
|||||||
"publisher": {"@type": "Organization", "name": brand},
|
"publisher": {"@type": "Organization", "name": brand},
|
||||||
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
|
"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>"
|
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||||
return _shell(
|
return _shell(
|
||||||
title=f"{title} | {brand}",
|
title=f"{title} | {brand}",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
|
|
||||||
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||||
|
|
||||||
@@ -361,3 +362,218 @@ def test_article_body_url_sanitization(auth_client):
|
|||||||
body = res.json()["body"]
|
body = res.json()["body"]
|
||||||
marks = body["content"][0]["content"][0].get("marks", [])
|
marks = body["content"][0]["content"][0].get("marks", [])
|
||||||
assert all("href" not in m.get("attrs", {}) for m in 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
@@ -6,6 +6,16 @@
|
|||||||
#
|
#
|
||||||
# همه درخواستهای HTTP به HTTPS هدایت میشوند و مسیریابیها فقط روی دامنه DOMAIN پاسخ میدهند.
|
# همه درخواستهای HTTP به HTTPS هدایت میشوند و مسیریابیها فقط روی دامنه DOMAIN پاسخ میدهند.
|
||||||
# برای داشبوردها: analytics.DOMAIN (آنالیتیکس) و grafana.DOMAIN (مانیتورینگ، با فایل monitoring).
|
# برای داشبوردها: 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:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
@@ -43,7 +53,7 @@ services:
|
|||||||
- traefik.http.routers.media.entrypoints=websecure
|
- traefik.http.routers.media.entrypoints=websecure
|
||||||
- traefik.http.routers.media.tls.certresolver=le
|
- traefik.http.routers.media.tls.certresolver=le
|
||||||
- traefik.http.routers.media.priority=100
|
- 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.regex=^/media/(.*)
|
||||||
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
||||||
- traefik.http.services.media.loadbalancer.server.port=9000
|
- traefik.http.services.media.loadbalancer.server.port=9000
|
||||||
@@ -53,16 +63,18 @@ services:
|
|||||||
# سایت هممبدأ است — CORS لازم نیست
|
# سایت هممبدأ است — CORS لازم نیست
|
||||||
CORS_ORIGINS: '[]'
|
CORS_ORIGINS: '[]'
|
||||||
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
|
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
|
||||||
|
# پیشنمایش خصوصی — هدر noindex + robots مسدودکننده (لایه دوم؛ لایه اول گیت Traefik)
|
||||||
|
PREVIEW_MODE: ${PREVIEW_MODE:-off}
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
||||||
- traefik.http.routers.api.entrypoints=websecure
|
- traefik.http.routers.api.entrypoints=websecure
|
||||||
- traefik.http.routers.api.tls.certresolver=le
|
- 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.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.entrypoints=websecure
|
||||||
- traefik.http.routers.seo.tls.certresolver=le
|
- 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:
|
frontend:
|
||||||
labels:
|
labels:
|
||||||
@@ -70,12 +82,28 @@ services:
|
|||||||
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
|
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
|
||||||
- traefik.http.routers.web.entrypoints=websecure
|
- traefik.http.routers.web.entrypoints=websecure
|
||||||
- traefik.http.routers.web.tls.certresolver=le
|
- 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 استفاده میشود
|
# تعریف یکبارهٔ میانافزار هدرهای امنیتی — روی همهٔ روترهای websecure استفاده میشود
|
||||||
- traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000
|
- traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000
|
||||||
- traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true
|
- traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true
|
||||||
- traefik.http.middlewares.sec-headers.headers.stsPreload=true
|
- traefik.http.middlewares.sec-headers.headers.stsPreload=true
|
||||||
- traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=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:
|
chatbot:
|
||||||
labels:
|
labels:
|
||||||
@@ -84,7 +112,7 @@ services:
|
|||||||
- traefik.http.routers.chat.entrypoints=websecure
|
- traefik.http.routers.chat.entrypoints=websecure
|
||||||
- traefik.http.routers.chat.tls.certresolver=le
|
- traefik.http.routers.chat.tls.certresolver=le
|
||||||
- traefik.http.routers.chat.priority=100
|
- 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:
|
umami:
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user