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"
/>