static and media url fix

This commit is contained in:
javad committed 2026-09-11 12:42:30 +03:30
1 parent 3a8f428461
commit 372f962f0f
7 files changed
+47 -9

No files matched your search

+1
View File
@@ -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
+1 -1
View File
@@ -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
+19 -2
View File
@@ -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
+6 -2
View File
@@ -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
+3 -3
View File
@@ -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 واقعی بفرستید.
+1 -1
View File
@@ -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 موفق الزام‌آور است.
## محدودیت‌های ایمنی
+16
View File
@@ -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