diff --git a/ENV_TEMPLATE.txt b/ENV_TEMPLATE.txt index d5a0fa9..7c58821 100644 --- a/ENV_TEMPLATE.txt +++ b/ENV_TEMPLATE.txt @@ -25,7 +25,10 @@ DJANGO_PORT=8000 # CORS Settings (comma-separated origins) # Example: CORS_ALLOWED_ORIGINS=http://YOUR_SERVER_IP:9123,https://your-frontend.com -CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,https://panel.zoneco.org,https://zoneco.org,https://www.zoneco.org + +# HTTPS origins trusted for CSRF (needed behind nginx / Arvan) +CSRF_TRUSTED_ORIGINS=https://zoneco.org,https://www.zoneco.org,https://panel.zoneco.org # Admin bootstrap (used by Docker entrypoint on server) # Leave empty locally if you create the user manually diff --git a/api/tests.py b/api/tests.py index 407f53a..29137f3 100644 --- a/api/tests.py +++ b/api/tests.py @@ -3,6 +3,7 @@ from datetime import timedelta from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings from django.utils import timezone from PIL import Image from rest_framework import status @@ -343,6 +344,26 @@ class AdminLoginAPITests(APITestCase): self.assertIn('token', response.data) self.assertEqual(response.data['user']['username'], self.admin_username) + def test_admin_login_without_trailing_slash(self): + """Axios/fetch omit the slash; this used to 500 with APPEND_SLASH + DEBUG.""" + response = self.client.post( + '/api/admin/login', + {'username': self.admin_username, 'password': self.admin_password}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('token', response.data) + + def test_admin_login_without_trailing_slash_debug(self): + with override_settings(DEBUG=True): + response = self.client.post( + '/api/admin/login', + {'username': self.admin_username, 'password': self.admin_password}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('token', response.data) + def test_admin_login_wrong_password(self): response = self.client.post( '/api/admin/login/', diff --git a/api/urls.py b/api/urls.py index 7ee0599..f1af15c 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,5 +1,6 @@ -from django.urls import path, include +from django.urls import include, re_path from rest_framework.routers import DefaultRouter + from .views import ( ContactUsViewSet, CompositionViewSet, @@ -9,14 +10,20 @@ from .views import ( admin_me, ) -router = DefaultRouter() + +class OptionalSlashRouter(DefaultRouter): + """Match /api/resource and /api/resource/ so POST clients do not hit APPEND_SLASH.""" + trailing_slash = '/?' + + +router = OptionalSlashRouter() router.register(r'contact-us', ContactUsViewSet, basename='contact-us') router.register(r'compositions', CompositionViewSet, basename='composition') router.register(r'campaigns', CampaignViewSet, basename='campaign') urlpatterns = [ - path('admin/login/', admin_login, name='admin-login'), - path('admin/logout/', admin_logout, name='admin-logout'), - path('admin/me/', admin_me, name='admin-me'), - path('', include(router.urls)), + re_path(r'^admin/login/?$', admin_login, name='admin-login'), + re_path(r'^admin/logout/?$', admin_logout, name='admin-logout'), + re_path(r'^admin/me/?$', admin_me, name='admin-me'), + re_path(r'^', include(router.urls)), ] diff --git a/api/views.py b/api/views.py index ddb3415..b04faa6 100644 --- a/api/views.py +++ b/api/views.py @@ -1,5 +1,10 @@ from rest_framework import viewsets, status -from rest_framework.decorators import action, api_view, permission_classes +from rest_framework.decorators import ( + action, + api_view, + authentication_classes, + permission_classes, +) from rest_framework.response import Response from rest_framework.permissions import AllowAny, IsAuthenticated, IsAdminUser from rest_framework.parsers import MultiPartParser, FormParser, JSONParser @@ -17,6 +22,7 @@ from .serializers import ( @api_view(['POST']) +@authentication_classes([]) @permission_classes([AllowAny]) def admin_login(request): """ diff --git a/zonco_backend/settings.py b/zonco_backend/settings.py index c15903f..e47dde1 100644 --- a/zonco_backend/settings.py +++ b/zonco_backend/settings.py @@ -149,12 +149,32 @@ MEDIA_ROOT = BASE_DIR / 'media' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +# Nginx / Arvan terminate TLS. Without this, Django builds http:// URLs and +# APPEND_SLASH / CSRF see the request as HTTP. +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +USE_X_FORWARDED_HOST = True + +csrf_origins_env = os.environ.get('CSRF_TRUSTED_ORIGINS', '') +CSRF_TRUSTED_ORIGINS = [o.strip() for o in csrf_origins_env.split(',') if o.strip()] +if not CSRF_TRUSTED_ORIGINS: + CSRF_TRUSTED_ORIGINS = [ + 'https://zoneco.org', + 'https://www.zoneco.org', + 'https://panel.zoneco.org', + ] + if DEBUG: + CSRF_TRUSTED_ORIGINS += [ + 'http://localhost:5173', + 'http://localhost:3000', + 'http://127.0.0.1:5173', + 'http://127.0.0.1:3000', + ] + # REST Framework configuration REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.TokenAuthentication', 'rest_framework.authentication.BasicAuthentication', - 'rest_framework.authentication.SessionAuthentication', ], 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.AllowAny',