Browse Source

fix(podcast): resolve audio playback redirect loop with HTTPS enforcement and cache-buster

- Enforce HTTPS for podcast audio files in serializers and admin field
- Add cache-busting version query param to bypass Cloudflare 301 cached redirect
- Configure SECURE_PROXY_SSL_HEADER and USE_X_FORWARDED_HOST/PORT in settings
- Update nginx config with HTTP to HTTPS redirect, media range/CORS headers and X-Forwarded-Proto handling
master
Mohsen Taba 13 hours ago
parent
commit
f82a30b630
  1. 37
      apps/podcast/serializers.py
  2. 4
      apps/podcast/serializers_admin.py
  3. 5
      config/settings/base.py
  4. 2
      config/settings/production.py
  5. 47
      nginx/dovodi.conf

37
apps/podcast/serializers.py

@ -16,6 +16,29 @@ class PodcastCategoryListSerializer(serializers.ModelSerializer):
return obj.playlists.filter(status=True, playlist_items__podcast__status=True).distinct().count() return obj.playlists.filter(status=True, playlist_items__podcast__status=True).distinct().count()
def build_podcast_audio_url(obj, request=None):
"""
Build absolute HTTPS URL for podcast audio file with cache-busting query parameter.
Bypasses poisoned Cloudflare 301 cache redirects and enforces HTTPS.
"""
if not obj.audio_file:
return None
url = obj.audio_file.url
if request:
full_url = request.build_absolute_uri(url)
else:
full_url = url
# 1. Force HTTPS protocol (except local dev)
if full_url.startswith('http://') and 'localhost' not in full_url and '127.0.0.1' not in full_url:
full_url = 'https://' + full_url[7:]
# 2. Bypass poisoned Cloudflare 301 redirect cache
separator = '&' if '?' in full_url else '?'
version = int(obj.updated_at.timestamp()) if hasattr(obj, 'updated_at') and obj.updated_at else getattr(obj, 'id', 1)
return f"{full_url}{separator}v={version}"
class PodcastListSerializer(serializers.ModelSerializer): class PodcastListSerializer(serializers.ModelSerializer):
thumbnail = serializers.SerializerMethodField() thumbnail = serializers.SerializerMethodField()
audio_file = serializers.SerializerMethodField() audio_file = serializers.SerializerMethodField()
@ -32,13 +55,8 @@ class PodcastListSerializer(serializers.ModelSerializer):
return get_thumbs(obj.thumbnail, self.context.get('request')) return get_thumbs(obj.thumbnail, self.context.get('request'))
def get_audio_file(self, obj): def get_audio_file(self, obj):
"""Get full URL for audio file if it exists"""
if obj.audio_file:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.audio_file.url)
return obj.audio_file.url
return None
"""Get full URL for audio file if it exists with HTTPS and cache-buster"""
return build_podcast_audio_url(obj, self.context.get('request'))
def get_audio_time(self, obj): def get_audio_time(self, obj):
return format_media_time(obj.audio_time) return format_media_time(obj.audio_time)
@ -68,6 +86,7 @@ class PodcastDetailSerializer(serializers.ModelSerializer):
in_user_playlist = serializers.SerializerMethodField() in_user_playlist = serializers.SerializerMethodField()
share_link = serializers.CharField(read_only=True) share_link = serializers.CharField(read_only=True)
audio_time = serializers.SerializerMethodField() audio_time = serializers.SerializerMethodField()
audio_file = serializers.SerializerMethodField()
class Meta: class Meta:
model = Podcast model = Podcast
fields = ['id', 'title', 'slug', 'thumbnail', 'description', fields = ['id', 'title', 'slug', 'thumbnail', 'description',
@ -75,6 +94,10 @@ class PodcastDetailSerializer(serializers.ModelSerializer):
'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark', 'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark',
'is_in_playlist', 'playlist_podcasts', 'in_user_playlist', 'share_link'] 'is_in_playlist', 'playlist_podcasts', 'in_user_playlist', 'share_link']
def get_audio_file(self, obj):
"""Get full URL for audio file if it exists with HTTPS and cache-buster"""
return build_podcast_audio_url(obj, self.context.get('request'))
def get_thumbnail(self, obj): def get_thumbnail(self, obj):
return get_thumbs(obj.thumbnail, self.context.get('request')) return get_thumbs(obj.thumbnail, self.context.get('request'))

4
apps/podcast/serializers_admin.py

@ -34,9 +34,7 @@ class AbsoluteFileField(serializers.FileField):
return None return None
request = self.context.get("request") request = self.context.get("request")
url = value.url if hasattr(value, "url") else str(value) url = value.url if hasattr(value, "url") else str(value)
if request:
return request.build_absolute_uri(url)
return url
return absolute_https_url(url, request)
class AdminPodcastCategorySerializer(serializers.ModelSerializer): class AdminPodcastCategorySerializer(serializers.ModelSerializer):

5
config/settings/base.py

@ -31,6 +31,11 @@ environ.Env.read_env(os.path.join(BASE_DIR, '.env'))
ALLOWED_HOSTS = env('DJANGO_ALLOWED_HOSTS').split(',') ALLOWED_HOSTS = env('DJANGO_ALLOWED_HOSTS').split(',')
CSRF_TRUSTED_ORIGINS = env('DJANGO_TRUSTED_ORIGINS').split(',') CSRF_TRUSTED_ORIGINS = env('DJANGO_TRUSTED_ORIGINS').split(',')
# Reverse Proxy / SSL settings (Cloudflare & Nginx)
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True
USE_X_FORWARDED_PORT = True
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/

2
config/settings/production.py

@ -9,6 +9,8 @@ DEBUG = False
CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOW_ALL_ORIGINS = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True
USE_X_FORWARDED_PORT = True

47
nginx/dovodi.conf

@ -1,6 +1,19 @@
# 1. Port 80 (HTTP) Server Block - Redirect all HTTP traffic to HTTPS
server { server {
listen 80; listen 80;
listen 443 ssl;
listen [::]:80;
server_name dovodi.nwhco.ir dovodi.newhorizonco.uk dovodi.com test.dovodi.com;
# Immediate 301 redirect to HTTPS without caching redirect
location / {
return 301 https://$host$request_uri;
}
}
# 2. Port 443 (HTTPS) Server Block
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name dovodi.nwhco.ir dovodi.newhorizonco.uk dovodi.com test.dovodi.com; server_name dovodi.nwhco.ir dovodi.newhorizonco.uk dovodi.com test.dovodi.com;
ssl_certificate /etc/nginx/certs/nwhco.pem; ssl_certificate /etc/nginx/certs/nwhco.pem;
ssl_certificate_key /etc/nginx/certs/nwhco.key; ssl_certificate_key /etc/nginx/certs/nwhco.key;
@ -21,9 +34,8 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
client_max_body_size 1200M; client_max_body_size 1200M;
} }
location /fa/dovoodi/ { location /fa/dovoodi/ {
@ -31,7 +43,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
client_max_body_size 1200M; client_max_body_size 1200M;
} }
@ -41,7 +53,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
client_max_body_size 1200M; client_max_body_size 1200M;
} }
@ -54,7 +66,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
client_max_body_size 1200M; client_max_body_size 1200M;
} }
@ -64,7 +76,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
client_max_body_size 1200M; client_max_body_size 1200M;
} }
@ -74,7 +86,7 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header Authorization $http_authorization; proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization; proxy_pass_header Authorization;
client_max_body_size 500M; client_max_body_size 500M;
@ -90,7 +102,7 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_method $request_method; proxy_method $request_method;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header Authorization $http_authorization; proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization; proxy_pass_header Authorization;
@ -129,13 +141,20 @@ server {
location /media/ { location /media/ {
alias /home/app/web/imam_javad_mediafiles/; alias /home/app/web/imam_javad_mediafiles/;
# Range Requests for audio streaming / seeking in player
add_header Accept-Ranges bytes;
# CORS headers for media streaming
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS' always;
add_header Access-Control-Allow-Headers 'Range, Origin, Content-Type, Accept' always;
# Standard static cache
expires 30d; expires 30d;
add_header Cache-Control "public, immutable";
add_header Cache-Control "public, no-transform";
# Enable CORS for media files
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept";
access_log off;
log_not_found off;
} }
# ========== Next.js Frontend ========== # ========== Next.js Frontend ==========

Loading…
Cancel
Save