diff --git a/.env.example b/.env.example index a0dd66c..bdaef77 100644 --- a/.env.example +++ b/.env.example @@ -1,37 +1,33 @@ -# --- App Hosting --- -# A comma-separated list of hostnames that the Vite dev server will accept. -# Required if you are accessing the frontend via a custom domain. -# Example: VITE_ALLOWED_HOSTS=holy.narl.io,localhost -VITE_ALLOWED_HOSTS=localhost +# --- Application Security --- +# Set to a long, random string in production +SECRET_KEY=django-insecure-replace-this-in-production -# A comma-separated list of domains that can access the backend API. -# Example: CORS_ALLOWED_ORIGINS=http://localhost:5173,https://holy.narl.io -CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 - -# A comma-separated list of hostnames that the Django backend will serve. -# Example: ALLOWED_HOSTS=localhost,backend,api.holy.narl.io -ALLOWED_HOSTS=localhost,127.0.0.1,backend - -# Set to "false" in production +# Set to "false" in production for security and performance DEBUG=true +# Custom URL for the Django Admin interface (e.g., secret-admin/) +ADMIN_URL=admin/ -# A comma-separated list of domains that Django will trust for CSRF purposes. -# This should match your frontend domain. -# Example: CSRF_TRUSTED_ORIGINS=https://holy.narl.io,http://localhost:5173 +# --- Hosting & Networking --- +# The primary domain where your app is hosted (e.g., holy.narl.io) +# Comma-separated list for backend to allow requests +ALLOWED_HOSTS=localhost,127.0.0.1,backend + +# CSRF protection: Comma-separated list of trusted origins (must start with http:// or https://) CSRF_TRUSTED_ORIGINS=http://localhost:5173 -# --- SMTP Configuration --- -# Rename this file to .env and fill in your details to enable email sending. +# CORS protection: Comma-separated list of allowed origins (must match your frontend domain) +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 -# --- Required for SMTP --- -EMAIL_HOST=smtp.example.com +# --- SMTP Configuration (Required for Email Verification) --- +# For development, if EMAIL_HOST is empty, emails are printed to the console. +EMAIL_HOST=smtp.gmail.com EMAIL_PORT=587 -EMAIL_HOST_USER=your-email@example.com -# Do not wrap the password in quotes or backticks -EMAIL_HOST_PASSWORD=your-app-password +EMAIL_USE_TLS=true +EMAIL_HOST_USER=your-email@gmail.com +EMAIL_HOST_PASSWORD=your-app-specific-password -# --- Optional (defaults are usually fine) --- -# EMAIL_USE_TLS=true -# For providers like Gmail using port 465, you might need to set this to true -# EMAIL_USE_SSL=true +# --- Frontend Configuration --- +# The base URL of the backend API (e.g., https://holy.narl.io/api/) +VITE_API_URL=http://localhost:8000/api/ +VITE_ALLOWED_HOSTS=localhost diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc088c0 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Holy Energy Archive & Community Hub + +A premium, responsive full-stack platform for "Holy Energy" fans to rate flavors, explore a categorized archive, and share personalized rating profiles. + +## Features + +- **Dynamic Hall of Fame:** An auto-rotating carousel featuring the top-rated flavors and community reviews. +- **Flavor Gallery:** A space-efficient, grid-based view of all rated and missing flavors. +- **Personalized Taste Profiles:** Share your ratings with the community using tiered (S-D) leaderboards. +- **Responsive Design:** Optimized for all devices, from ultra-wide monitors to vertical smartphones. +- **Secure Authentication:** SMTP-verified email signup, password resets, and session management. +- **Catalog Automation:** Automatic flavor syncing from official Shopify sources with local image caching. + +--- + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) +- [Docker Compose](https://docs.docker.com/compose/install/) +- [SMTP Server](https://support.google.com/mail/answer/185833?hl=en) (e.g., Gmail App Password) for email verification. + +--- + +## Quick Setup + +1. **Clone the repository:** + ```bash + git clone https://github.com/your-repo/holy.git + cd holy + ``` + +2. **Configure your environment:** + Copy the example environment file and fill in your specific details (especially SMTP and your domain name): + ```bash + cp .env.example .env + ``` + +3. **Start the containers:** + ```bash + docker-compose up -d --build + ``` + +4. **Wait for initial setup:** + The first time you start the backend, it will automatically: + - Run database migrations. + - Collect static files. + - **Sync flavors** from the Holy Energy Shopify API (this might take a minute). + +5. **Access the application:** + - **Frontend:** [http://localhost:5173](http://localhost:5173) (or your configured domain) + - **API Docs:** [http://localhost:8000/api/schema/swagger-ui/](http://localhost:8000/api/schema/swagger-ui/) + - **Admin Panel:** [http://localhost:8000/admin/](http://localhost:8000/admin/) (or your configured `ADMIN_URL`) + +--- + +## Production Hosting + +### Security Best Practices + +When hosting this application publicly, ensure you update your `.env` with: + +- `DEBUG=false`: Essential for disabling internal Django stack traces. +- `SECRET_KEY`: Use a long, random string. +- `ADMIN_URL`: Change this to something unique (e.g., `super-secret-gate/`) to hide your admin panel from scanners. +- `ALLOWED_HOSTS`, `CSRF_TRUSTED_ORIGINS`, `CORS_ALLOWED_ORIGINS`: Set these to your actual production domain (e.g., `https://holy.narl.io`). + +### Performance + +- The application uses **WhiteNoise** for efficient static file serving. +- **Gunicorn** is configured as the production WSGI server. +- **Compression Middleware** (Gzip/Brotli) is active for API responses. +- **Lazy Loading** is enabled for all flavor and carousel images. + +--- + +## Project Structure + +- `backend/`: Django REST Framework API. +- `frontend/`: React 19 application with Material UI and Catppuccin themes. +- `media/`: Locally cached product images and user avatars. +- `legacy/`: Static JSON data for historical flavors. + +## License + +MIT diff --git a/backend/api/models.py b/backend/api/models.py index 06f63e1..9607403 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -59,10 +59,10 @@ class Flavor(models.Model): image_url = models.URLField(max_length=1000, blank=True, null=True) image = models.ImageField(upload_to='flavors/', blank=True, null=True) shop_url = models.URLField(max_length=500, blank=True, null=True) - is_available = models.BooleanField(default=True) - is_legacy = models.BooleanField(default=False) + is_available = models.BooleanField(default=True, db_index=True) + is_legacy = models.BooleanField(default=False, db_index=True) external_id = models.BigIntegerField(unique=True, null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) def __str__(self): return self.name @@ -83,9 +83,9 @@ class Flavor(models.Model): class Rating(models.Model): user = models.ForeignKey(User, related_name='ratings', on_delete=models.CASCADE) flavor = models.ForeignKey(Flavor, related_name='ratings', on_delete=models.CASCADE) - score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)]) + score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)], db_index=True) comment = models.TextField(blank=True, null=True) - created_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) class Meta: unique_together = ('user', 'flavor') diff --git a/backend/api/views.py b/backend/api/views.py index 6529a92..bef014c 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -4,6 +4,8 @@ from rest_framework.response import Response from rest_framework.decorators import action from django.db.models import Avg from django_filters.rest_framework import DjangoFilterBackend +from django_ratelimit.decorators import ratelimit +from django.utils.decorators import method_decorator from .models import User, Flavor, Category, Rating, Reply from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer @@ -32,6 +34,7 @@ class RatingViewSet(viewsets.ModelViewSet): serializer_class = RatingSerializer permission_classes = [permissions.IsAuthenticatedOrReadOnly] + @method_decorator(ratelimit(key='user', rate='10/m', method='POST', block=True)) def perform_create(self, serializer): # Ensure user can only have one rating per flavor flavor = serializer.validated_data['flavor'] @@ -80,6 +83,7 @@ class UserViewSet(viewsets.ModelViewSet): return super().get_permissions() @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) + @method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True)) def signup(self, request): username = request.data.get('username') email = request.data.get('email') @@ -146,9 +150,7 @@ class UserViewSet(viewsets.ModelViewSet): except User.DoesNotExist: return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND) - ratings = user.ratings.select_related('flavor', 'flavor__category').exclude(flavor__category__slug='packs-and-other').all() - # Sort by score descending to highlight favorites - ratings = sorted(ratings, key=lambda x: x.score, reverse=True) + ratings = user.ratings.select_related('flavor', 'flavor__category').exclude(flavor__category__slug='packs-and-other').order_by('-score') return Response({ 'username': user.username, @@ -247,6 +249,7 @@ class UserViewSet(viewsets.ModelViewSet): return Response({'status': 'Email confirmed', 'email': user.email}) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) + @method_decorator(ratelimit(key='ip', rate='3/h', method='POST', block=True)) def request_password_reset(self, request): email = request.data.get('email') try: @@ -270,6 +273,7 @@ class UserViewSet(viewsets.ModelViewSet): return Response({'status': 'If an account exists with this email, a reset code has been sent.'}) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) + @method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True)) def complete_password_reset(self, request): email = request.data.get('email') code = request.data.get('code') diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index cd5a518..7eb565e 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -20,4 +20,4 @@ python manage.py sync_flavors # Start the server using Gunicorn echo "Starting Gunicorn..." -exec gunicorn holy_backend.wsgi:application --bind 0.0.0.0:8000 --workers 3 +exec gunicorn holy_backend.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120 diff --git a/backend/holy_backend/settings.py b/backend/holy_backend/settings.py index 2da25f1..d504a8c 100644 --- a/backend/holy_backend/settings.py +++ b/backend/holy_backend/settings.py @@ -21,12 +21,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-zos99ue2a89!4jrv4g@_b4nn4z!5o*@8k!5cr9!u$j%_@xb=l8' +SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-zos99ue2a89!4jrv4g@_b4nn4z!5o*@8k!5cr9!u$j%_@xb=l8') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = os.environ.get('DEBUG', 'true').lower() == 'true' +DEBUG = os.environ.get('DEBUG', 'false').lower() == 'true' -ALLOWED_HOSTS_STRING = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1,backend') +ALLOWED_HOSTS_STRING = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1,backend,holy.narl.io') ALLOWED_HOSTS = [host.strip() for host in ALLOWED_HOSTS_STRING.split(',')] @@ -46,6 +46,7 @@ INSTALLED_APPS = [ 'corsheaders', 'django_filters', 'drf_spectacular', + 'django_ratelimit', # Local 'api', ] @@ -53,6 +54,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', + 'compression_middleware.middleware.CompressionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', @@ -141,8 +143,19 @@ USE_X_FORWARDED_HOST = True USE_X_FORWARDED_PORT = True SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +# Production Security +if not DEBUG: + SECURE_SSL_REDIRECT = True + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + SECURE_HSTS_SECONDS = 31536000 # 1 year + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_HSTS_PRELOAD = True + # Read CSRF trusted origins from environment variable -CSRF_TRUSTED_ORIGINS_STRING = os.environ.get('CSRF_TRUSTED_ORIGINS', 'http://localhost:5173') +CSRF_TRUSTED_ORIGINS_STRING = os.environ.get('CSRF_TRUSTED_ORIGINS', 'https://holy.narl.io,http://localhost:5173') CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in CSRF_TRUSTED_ORIGINS_STRING.split(',')] # Custom User Model @@ -158,11 +171,13 @@ REST_FRAMEWORK = { 'rest_framework.permissions.IsAuthenticatedOrReadOnly', ], 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 50, 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', } # CORS -CORS_ALLOWED_ORIGINS_STRING = os.environ.get('CORS_ALLOWED_ORIGINS', 'http://localhost:5173,http://127.0.0.1:5173') +CORS_ALLOWED_ORIGINS_STRING = os.environ.get('CORS_ALLOWED_ORIGINS', 'https://holy.narl.io,http://localhost:5173,http://127.0.0.1:5173') CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS_STRING.split(',')] import os diff --git a/backend/holy_backend/urls.py b/backend/holy_backend/urls.py index 43c5612..bb72800 100644 --- a/backend/holy_backend/urls.py +++ b/backend/holy_backend/urls.py @@ -7,9 +7,12 @@ from django.conf.urls.static import static from django.views.static import serve import re +import os + +admin_path = os.environ.get('ADMIN_URL', 'admin/') urlpatterns = [ - path('admin/', admin.site.urls), + path(admin_path, admin.site.urls), path('api/', include('api.urls')), path('api/token/', obtain_auth_token, name='api_token'), path('api-auth/', include('rest_framework.urls')), diff --git a/backend/requirements.txt b/backend/requirements.txt index 467e4a2..38c4cf8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,5 @@ Pillow>=10.2.0 drf-spectacular>=0.27.0 whitenoise>=6.6.0 gunicorn>=21.2.0 +django-ratelimit>=4.1.0 +django-compression-middleware>=0.5.0 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index af834c8..a4e44b1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, Suspense, lazy } from 'react'; import { BrowserRouter as Router, Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom'; import { ThemeProvider, @@ -21,7 +21,8 @@ import { Collapse, TextField, InputAdornment, - Autocomplete + Autocomplete, + CircularProgress } from '@mui/material'; import MenuIcon from '@mui/icons-material/Menu'; import SearchIcon from '@mui/icons-material/Search'; @@ -29,15 +30,17 @@ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; import { getTheme, type CatppuccinTheme } from './theme'; -import CategoryList from './pages/CategoryList'; -import CategoryFlavors from './pages/categories/CategoryFlavors'; -import FlavorDetail from './pages/flavors/FlavorDetail'; -import Dashboard from './pages/Dashboard'; -import PublicProfile from './pages/PublicProfile'; -import Login from './pages/Login'; -import Settings from './pages/Settings'; import api from './api'; +// Code splitting for routes +const CategoryList = lazy(() => import('./pages/CategoryList')); +const CategoryFlavors = lazy(() => import('./pages/categories/CategoryFlavors')); +const FlavorDetail = lazy(() => import('./pages/flavors/FlavorDetail')); +const Dashboard = lazy(() => import('./pages/Dashboard')); +const PublicProfile = lazy(() => import('./pages/PublicProfile')); +const Login = lazy(() => import('./pages/Login')); +const Settings = lazy(() => import('./pages/Settings')); + interface SearchFlavor { id: number; name: string; @@ -371,15 +374,17 @@ const App: React.FC = () => { - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + diff --git a/frontend/src/pages/CategoryList.tsx b/frontend/src/pages/CategoryList.tsx index 6a1fc65..15459ad 100644 --- a/frontend/src/pages/CategoryList.tsx +++ b/frontend/src/pages/CategoryList.tsx @@ -159,6 +159,7 @@ const CategoryList: React.FC = () => { transition: 'transform 0.5s ease', '&:hover': { transform: 'scale(1.1)' } }} + loading="lazy" /> )} @@ -260,6 +261,7 @@ const CategoryList: React.FC = () => { component="img" src={currentTop.image_url || ''} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} + loading="lazy" /> diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index d1a2f0a..c1f01e8 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -137,6 +137,7 @@ const Dashboard: React.FC = () => { component="img" src={rating.flavor_image || undefined} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} + loading="lazy" /> { component="img" src={flavor.image_url || undefined} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} + loading="lazy" /> diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index 91a0237..b337c01 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -166,6 +166,7 @@ const PublicProfile: React.FC = () => { component="img" src={rating.flavor_image || undefined} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} + loading="lazy" />