From 372f962f0f05c30e1091c1075182d6fb1d28b083 Mon Sep 17 00:00:00 2001 From: javad Date: Fri, 11 Sep 2026 12:42:30 +0330 Subject: [PATCH] static and media url fix --- .env.example | 1 + config/settings/prod.py | 2 +- docker-compose.yml | 21 +++++++++++++++++++-- docker/entrypoint.sh | 8 ++++++-- docs/DEPLOYMENT_COOLIFY.md | 6 +++--- docs/PRODUCTION_SEED.md | 2 +- tests/test_deployment.py | 16 ++++++++++++++++ 7 files changed, 47 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index e1bb8c4..c8adc17 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ DJANGO_SECRET_KEY=replace-with-a-unique-random-secret-of-at-least-50-characters DJANGO_ALLOWED_HOSTS=support.example.com CSRF_TRUSTED_ORIGINS=https://support.example.com PUBLIC_BASE_URL=https://support.example.com +ASSET_HOST=connect.loolegostar.com WEBOTP_HOST=support.example.com TIME_ZONE=Asia/Tehran diff --git a/config/settings/prod.py b/config/settings/prod.py index 901b9ae..55575d8 100644 --- a/config/settings/prod.py +++ b/config/settings/prod.py @@ -10,7 +10,7 @@ if not env_list("DJANGO_ALLOWED_HOSTS"): raise ImproperlyConfigured("DJANGO_ALLOWED_HOSTS must contain the public production host.") if not CSRF_TRUSTED_ORIGINS: raise ImproperlyConfigured("CSRF_TRUSTED_ORIGINS must contain the public HTTPS origin.") -if len(MEDIA_SIGNING_SECRET) < 32: +if MEDIA_SIGNING_SECRET == "Set MEDIA_SIGNING_SECRET in Coolify" or len(MEDIA_SIGNING_SECRET) < 32: raise ImproperlyConfigured("MEDIA_SIGNING_SECRET must be a random secret of at least 32 characters.") SESSION_COOKIE_SECURE = True diff --git a/docker-compose.yml b/docker-compose.yml index 2f9f571..b18292f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ x-django-service: &django-service DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:?Set DJANGO_ALLOWED_HOSTS in Coolify} CSRF_TRUSTED_ORIGINS: ${CSRF_TRUSTED_ORIGINS:?Set CSRF_TRUSTED_ORIGINS in Coolify} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:?Set PUBLIC_BASE_URL in Coolify} + ASSET_HOST: ${ASSET_HOST:-connect.loolegostar.com} WEBOTP_HOST: ${WEBOTP_HOST:-} TIME_ZONE: ${TIME_ZONE:-Asia/Tehran} DATABASE_URL: ${DATABASE_URL:-} @@ -27,7 +28,7 @@ x-django-service: &django-service PRODUCTION_SEED_DIR: /app/production-seed REQUIRE_PRODUCTION_SEED: ${REQUIRE_PRODUCTION_SEED:-1} MEDIA_ROOT: /app/media - MEDIA_SIGNING_SECRET: ${MEDIA_SIGNING_SECRET:?Set MEDIA_SIGNING_SECRET in Coolify} + MEDIA_SIGNING_SECRET: ${MEDIA_SIGNING_SECRET:?} USE_NGINX_MEDIA: "true" SMS_PROVIDER_CLASS: ${SMS_PROVIDER_CLASS:-apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider} SMS0098_USERNAME: ${SMS0098_USERNAME:-} @@ -86,6 +87,22 @@ services: restart: unless-stopped networks: - coolify + labels: + - "traefik.enable=true" + - "traefik.docker.network=coolify" + - "traefik.http.middlewares.customer-ticket-assets-redirect-${COOLIFY_RESOURCE_UUID:-local}.redirectscheme.scheme=https" + - "traefik.http.routers.customer-ticket-assets-http-${COOLIFY_RESOURCE_UUID:-local}.entrypoints=http" + - "traefik.http.routers.customer-ticket-assets-http-${COOLIFY_RESOURCE_UUID:-local}.rule=Host(`${ASSET_HOST:-connect.loolegostar.com}`) && (PathPrefix(`/static/`) || PathPrefix(`/media/branding/`) || PathPrefix(`/protected-media/`))" + - "traefik.http.routers.customer-ticket-assets-http-${COOLIFY_RESOURCE_UUID:-local}.priority=1000" + - "traefik.http.routers.customer-ticket-assets-http-${COOLIFY_RESOURCE_UUID:-local}.middlewares=customer-ticket-assets-redirect-${COOLIFY_RESOURCE_UUID:-local}" + - "traefik.http.routers.customer-ticket-assets-http-${COOLIFY_RESOURCE_UUID:-local}.service=customer-ticket-assets-${COOLIFY_RESOURCE_UUID:-local}" + - "traefik.http.routers.customer-ticket-assets-https-${COOLIFY_RESOURCE_UUID:-local}.entrypoints=https" + - "traefik.http.routers.customer-ticket-assets-https-${COOLIFY_RESOURCE_UUID:-local}.rule=Host(`${ASSET_HOST:-connect.loolegostar.com}`) && (PathPrefix(`/static/`) || PathPrefix(`/media/branding/`) || PathPrefix(`/protected-media/`))" + - "traefik.http.routers.customer-ticket-assets-https-${COOLIFY_RESOURCE_UUID:-local}.priority=1000" + - "traefik.http.routers.customer-ticket-assets-https-${COOLIFY_RESOURCE_UUID:-local}.service=customer-ticket-assets-${COOLIFY_RESOURCE_UUID:-local}" + - "traefik.http.routers.customer-ticket-assets-https-${COOLIFY_RESOURCE_UUID:-local}.tls=true" + - "traefik.http.routers.customer-ticket-assets-https-${COOLIFY_RESOURCE_UUID:-local}.tls.certresolver=letsencrypt" + - "traefik.http.services.customer-ticket-assets-${COOLIFY_RESOURCE_UUID:-local}.loadbalancer.server.port=80" expose: - "80" depends_on: @@ -96,7 +113,7 @@ services: - static_data:/app/staticfiles:ro - media_data:/app/media:ro environment: - MEDIA_SIGNING_SECRET: ${MEDIA_SIGNING_SECRET:?Set MEDIA_SIGNING_SECRET in Coolify} + MEDIA_SIGNING_SECRET: ${MEDIA_SIGNING_SECRET:?} healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/healthz/ || exit 1"] interval: 15s diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 91b61b1..1627dbe 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -40,8 +40,12 @@ if [ "${RUN_STARTUP_TASKS:-0}" = "1" ]; then seed_dir="${PRODUCTION_SEED_DIR:-/app/production-seed}" seed_available=0 - if [ -f "$seed_dir/manifest.json" ]; then - echo "==> Importing production seed (idempotent)..." + seed_applied="$(python manage.py shell -c 'from apps.core.models import ProductionSeedState; print("1" if ProductionSeedState.objects.exists() else "0")')" + if [ "$seed_applied" = "1" ]; then + echo "==> Production seed was already applied; skipping seed import." + seed_available=1 + elif [ -f "$seed_dir/manifest.json" ]; then + echo "==> Importing production seed for the first and only time..." python manage.py import_production_seed "$seed_dir" seed_available=1 elif [ "${REQUIRE_PRODUCTION_SEED:-1}" = "1" ]; then diff --git a/docs/DEPLOYMENT_COOLIFY.md b/docs/DEPLOYMENT_COOLIFY.md index 6ab2f76..93a51ad 100644 --- a/docs/DEPLOYMENT_COOLIFY.md +++ b/docs/DEPLOYMENT_COOLIFY.md @@ -24,9 +24,9 @@ customer_ticket_seed ## 2. دامنه‌ها و Traefik خود Coolify -دامنه اصلی HTTPS را در Coolify مستقیماً به سرویس `web` و پورت `8000` متصل کنید. Traefik خود Coolify TLS، reverse proxy و هدر `X-Forwarded-Proto` را مدیریت می‌کند؛ Nginx جلوی Django قرار نمی‌گیرد. +دامنه اصلی HTTPS را در Coolify مستقیماً به سرویس `web` و پورت `8000` متصل کنید. مقدار Domain سرویس web باید دقیقاً یک URL و بدون فاصله یا کامای انتهایی باشد. Traefik خود Coolify TLS، reverse proxy و هدر `X-Forwarded-Proto` را مدیریت می‌کند؛ Nginx جلوی Django قرار نمی‌گیرد. -برای همان دامنه، routeهای `/static/`، `/media/branding/` و `/protected-media/` را به سرویس `nginx` و پورت `80` بدهید و مطمئن شوید routeهای path-based نسبت به route عمومی web اولویت بالاتری دارند. برای `notification-worker` هیچ domain یا host port عمومی نسازید. اگر نسخه Coolify شما تعریف چند route روی یک دامنه را از UI پشتیبانی نمی‌کند، routing rule متناظر Traefik را برای این سه path به nginx اضافه کنید؛ معماری containerها همچنان بدون proxy_pass بین Nginx و Django است. +فیلد Domain سرویس‌های `nginx` و `notification-worker` را در UI کاملاً خالی بگذارید. Compose برای Nginx routerهای صریح HTTP/HTTPS با priority برابر 1000 می‌سازد و `/static/`، `/media/branding/` و `/protected-media/` را بدون StripPrefix به پورت 80 می‌فرستد. hostname این ruleها از `ASSET_HOST` خوانده می‌شود. معماری containerها همچنان بدون `proxy_pass` بین Nginx و Django است. فایل‌های آپلودی تیکت عمومی نیستند: Django ابتدا مجوز را بررسی می‌کند و سپس کاربر را با یک URL امضاشده پنج‌دقیقه‌ای به `/protected-media/` روی Nginx می‌فرستد. branding عمومی زیر `/media/branding/` مستقیماً از Nginx سرو می‌شود. @@ -122,7 +122,7 @@ SECURE_HSTS_PRELOAD=false 1. volumeها و database خالی را ایجاد کنید. 2. طبق `docs/PRODUCTION_SEED.md` سه فایل seed را داخل volume `customer_ticket_seed` قرار دهید. 3. Environment Variables را در Coolify وارد کنید. -4. Compose را deploy، دامنه اصلی را به `web:8000` و pathهای static/media گفته‌شده را به `nginx:80` متصل کنید. +4. `ASSET_HOST` را روی hostname عمومی بگذارید، Domain سرویس web را بدون کامای انتهایی تنظیم کنید و Domainهای nginx/worker را خالی نگه دارید؛ سپس Compose را deploy کنید. 5. log سرویس `web` را برای موفقیت migrate، import production seed، collectstatic و Gunicorn بررسی کنید. وقتی seed موجود است، bootstrap عمداً اجرا نمی‌شود تا snapshot تغییر نکند. 6. log سرویس `notification-worker` را بررسی کنید؛ container باید Running بماند. 7. با superuser منتقل‌شده از SQLite وارد شوید و با گیرنده آزمایشی مجاز یک SMS و یک پیام Bale واقعی بفرستید. diff --git a/docs/PRODUCTION_SEED.md b/docs/PRODUCTION_SEED.md index 95eb2aa..ce2f0fa 100644 --- a/docs/PRODUCTION_SEED.md +++ b/docs/PRODUCTION_SEED.md @@ -65,7 +65,7 @@ docker run --rm \ 8. رد کردن `bootstrap` برای حفظ دقیق snapshot به‌عنوان منبع authoritative 9. اجرای `collectstatic` و Gunicorn -در deployهای بعدی checksum قبلاً ثبت‌شده تشخیص داده می‌شود و import دوباره انجام نمی‌شود. اگر bundle نباشد، با `REQUIRE_PRODUCTION_SEED=1` سرویس عمداً متوقف می‌شود تا production ناقص بالا نیاید. +در deployهای بعدی ابتدا marker دیتابیسی `ProductionSeedState` بررسی می‌شود. اگر marker وجود داشته باشد، فرمان import اصلاً اجرا نمی‌شود و نبودن bundle نیز مانع startup نخواهد شد. `REQUIRE_PRODUCTION_SEED=1` فقط تا پیش از اولین import موفق الزام‌آور است. ## محدودیت‌های ایمنی diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 0856c40..6affae6 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -45,6 +45,22 @@ def test_nginx_serves_assets_without_proxying_django(): assert "media_data:/app/media:ro" in compose assert "notification-worker:" in compose assert " db:" not in compose + assert "PathPrefix(`/static/`)" in compose + assert "PathPrefix(`/media/branding/`)" in compose + assert "PathPrefix(`/protected-media/`)" in compose + assert "priority=1000" in compose + assert "stripprefix" not in compose.lower() + assert "Set MEDIA_SIGNING_SECRET in Coolify" not in compose + + +def test_startup_imports_production_seed_only_when_database_has_no_seed_marker(): + root = Path(__file__).resolve().parents[1] + entrypoint = (root / "docker" / "entrypoint.sh").read_text(encoding="utf-8") + marker_check = "ProductionSeedState.objects.exists()" + import_command = 'python manage.py import_production_seed "$seed_dir"' + assert marker_check in entrypoint + assert entrypoint.index(marker_check) < entrypoint.index(import_command) + assert "Production seed was already applied; skipping seed import." in entrypoint @pytest.mark.django_db