updated build/instructions

This commit is contained in:
nvrl committed 2026-02-16 17:02:07 +01:00
1 parent d9aeeaaab8
commit e8ed7bf7ae
12 files changed
+176 -61

No files matched your search

+24 -28
View File
@@ -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
+85
View File
@@ -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
+5 -5
View File
@@ -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')
+7 -3
View File
@@ -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')
+1 -1
View File
@@ -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
+20 -5
View File
@@ -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
+4 -1
View File
@@ -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')),
+2
View File
@@ -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
+23 -18
View File
@@ -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 = () => {
</Drawer>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<Routes>
<Route path="/" element={<CategoryList />} />
<Route path="/category/:slug" element={<CategoryFlavors />} />
<Route path="/flavor/:id" element={<FlavorDetail />} />
<Route path="/profile/:username" element={<PublicProfile />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
<Route path="/login" element={<Login />} />
</Routes>
<Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>}>
<Routes>
<Route path="/" element={<CategoryList />} />
<Route path="/category/:slug" element={<CategoryFlavors />} />
<Route path="/flavor/:id" element={<FlavorDetail />} />
<Route path="/profile/:username" element={<PublicProfile />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
<Route path="/login" element={<Login />} />
</Routes>
</Suspense>
</Box>
</Box>
</Router>
+2
View File
@@ -159,6 +159,7 @@ const CategoryList: React.FC = () => {
transition: 'transform 0.5s ease',
'&:hover': { transform: 'scale(1.1)' }
}}
loading="lazy"
/>
</Box>
)}
@@ -260,6 +261,7 @@ const CategoryList: React.FC = () => {
component="img"
src={currentTop.image_url || ''}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/>
</Box>
<CardContent sx={{ flex: 1, p: { xs: 3, sm: 4, md: 6 }, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
+2
View File
@@ -137,6 +137,7 @@ const Dashboard: React.FC = () => {
component="img"
src={rating.flavor_image || undefined}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/>
<Box sx={{
position: 'absolute',
@@ -205,6 +206,7 @@ const Dashboard: React.FC = () => {
component="img"
src={flavor.image_url || undefined}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/>
</Box>
<CardContent sx={{ p: 1, '&:last-child': { pb: 1 } }}>
+1
View File
@@ -166,6 +166,7 @@ const PublicProfile: React.FC = () => {
component="img"
src={rating.flavor_image || undefined}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/>
<Box sx={{
position: 'absolute', bottom: 0, left: 0, right: 0,