Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ uploads/
.env
.flaskenv

# Test scripts
test_upload.py
test_b2_connection.py
diagnose_ssl.py
test_b2_insecure.py

# IDE
.vscode/
.idea/
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help install run down restart test clean format lint check migrate createsuperuser
.PHONY: help install run down restart test clean format lint check migrate createsuperuser shell

PYTHON := backend/venv/bin/python
PYTEST := backend/venv/bin/pytest
Expand All @@ -16,6 +16,7 @@ help:
@echo " make check Run Django system checks"
@echo " make migrate Run database migrations"
@echo " make createsuperuser Create Django superuser"
@echo " make shell Open Django shell"

install:
cd backend && venv/bin/pip install -r ../requirements.txt
Expand Down Expand Up @@ -54,3 +55,6 @@ migrate:

createsuperuser:
cd backend && ../$(PYTHON) manage.py createsuperuser

shell:
cd backend && ../$(PYTHON) manage.py shell
34 changes: 34 additions & 0 deletions backend/config/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""
Custom decorators for database error handling
"""
from functools import wraps
from django.db.utils import OperationalError, DatabaseError
from django.http import JsonResponse
from django.shortcuts import render


def handle_database_errors(view_func):
"""
Decorator to catch database errors in views and return graceful error responses
Use this on views that might fail if database is down
"""
@wraps(view_func)
def wrapper(request, *args, **kwargs):
try:
return view_func(request, *args, **kwargs)
except (OperationalError, DatabaseError) as e:
# Log the error
print(f"Database error in {view_func.__name__}: {e}")

# Return appropriate error response based on request type
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.content_type == 'application/json':
# For AJAX/API requests, return JSON error
return JsonResponse({
'error': 'Database temporarily unavailable',
'message': 'Please try again in a moment'
}, status=503)
else:
# For regular requests, render error page
return render(request, 'errors/database_error.html', status=503)

return wrapper
106 changes: 106 additions & 0 deletions backend/config/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""
Custom middleware for database connection handling
"""
import time
from django.conf import settings
from django.db import connection
from django.db.utils import OperationalError
from django.http import HttpResponse
from django.shortcuts import render


class DatabaseHealthCheckMiddleware:
"""
Middleware to handle database connection issues gracefully with retry logic
"""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
# Try to ensure database connection with retries
if not self._ensure_database_connection():
# If all retries failed, show a friendly error page
return self._render_database_error_page(request)

response = self.get_response(request)
return response

def _ensure_database_connection(self):
"""
Attempt to connect to database with exponential backoff retries
Returns True if successful, False otherwise
"""
for attempt in range(settings.DATABASE_RETRY_ATTEMPTS):
try:
# Try a simple database query to check connection
connection.ensure_connection()
return True
except OperationalError as e:
# If this is the last attempt, give up
if attempt == settings.DATABASE_RETRY_ATTEMPTS - 1:
print(f"Database connection failed after {settings.DATABASE_RETRY_ATTEMPTS} attempts")
return False

# Wait before retrying (exponential backoff)
delay = settings.DATABASE_RETRY_DELAYS[attempt]
print(f"Database connection attempt {attempt + 1} failed, retrying in {delay}s...")
time.sleep(delay)

return False

def _render_database_error_page(self, request):
"""
Render a friendly error page when database is unavailable
"""
html = """
<!DOCTYPE html>
<html>
<head>
<title>Service Temporarily Unavailable</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 2rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 1rem;
backdrop-filter: blur(10px);
max-width: 500px;
}
h1 { font-size: 2.5rem; margin-bottom: 1rem; }
p { font-size: 1.1rem; line-height: 1.6; }
.retry-btn {
margin-top: 2rem;
padding: 0.75rem 2rem;
font-size: 1rem;
background: white;
color: #667eea;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-weight: bold;
}
.retry-btn:hover { background: #f0f0f0; }
</style>
</head>
<body>
<div class="container">
<h1>We'll be right back!</h1>
<p>Our database is taking a quick nap. This usually resolves itself in a few moments.</p>
<p>Please try refreshing the page in a moment.</p>
<button class="retry-btn" onclick="location.reload()">Retry Now</button>
</div>
</body>
</html>
"""
return HttpResponse(html, status=503)
6 changes: 6 additions & 0 deletions backend/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", # Serve static files
"config.middleware.DatabaseHealthCheckMiddleware", # Database health check with retry
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
Expand Down Expand Up @@ -96,9 +97,14 @@
"default": dj_database_url.config(
default=os.environ.get("DATABASE_URL"),
conn_max_age=600,
conn_health_checks=True, # Enable connection health checks
)
}

# Database connection retry settings (exponential backoff: 1s, 2s, 4s)
DATABASE_RETRY_ATTEMPTS = 3
DATABASE_RETRY_DELAYS = [1, 2, 4] # Exponential backoff in seconds



# Password validation
Expand Down
43 changes: 8 additions & 35 deletions backend/listings/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,49 +138,22 @@ def save_picture_to_b2(form_picture):


def save_picture(form_picture):
"""Save picture to B2 if configured, otherwise save locally"""
"""Save picture to B2 storage only - no local fallback"""
# Validate file first
try:
validate_image_file(form_picture)
except ValidationError as e:
print(f"File validation error: {e}")
return None

# Try B2 first if configured and available
if B2_AVAILABLE and settings.B2_KEY_ID:
b2_filename = save_picture_to_b2(form_picture)
if b2_filename:
return b2_filename

# Fallback to local storage
random_hex = secrets.token_hex(8)
picture_fn = f"{random_hex}.jpg" # Always save as .jpg for smaller files
picture_path = os.path.join(settings.MEDIA_ROOT, picture_fn)

# Create upload directory if it doesn't exist
os.makedirs(os.path.dirname(picture_path), exist_ok=True)

# Resize and optimize image
img = Image.open(form_picture)

# Apply EXIF orientation to prevent rotation issues
try:
from PIL import ImageOps
img = ImageOps.exif_transpose(img)
except Exception:
pass # If EXIF orientation fails, continue without it

# Convert to RGB if necessary (for JPEG)
if img.mode in ("RGBA", "LA", "P"):
img = img.convert("RGB")

# Resize to max 800x600 while maintaining aspect ratio
img.thumbnail((800, 600), Image.Resampling.LANCZOS)

# Save as optimized JPEG
img.save(picture_path, format="JPEG", quality=85, optimize=True)
# B2 is required - fail if not configured
if not B2_AVAILABLE or not settings.B2_KEY_ID:
print("ERROR: B2 storage not configured")
return None

return picture_fn
# Upload to B2 - fail if it doesn't work
b2_filename = save_picture_to_b2(form_picture)
return b2_filename # Will be None if upload failed


def delete_photo_from_b2(filename):
Expand Down
30 changes: 14 additions & 16 deletions backend/listings/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,22 +157,20 @@ def user_login(request):
if request.method == "POST":
form = LoginForm(request, data=request.POST)
if form.is_valid():
email = form.cleaned_data.get("username")
password = form.cleaned_data.get("password")
user = authenticate(request, username=email, password=password)
if user is not None:
login(request, user)
messages.success(request, f"Welcome back, {user.first_name}!")

# Validate next parameter to prevent open redirects
next_url = request.GET.get("next", "index")
if url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure()
):
return redirect(next_url)
return redirect("index")
# AuthenticationForm validates credentials automatically
user = form.get_user()
login(request, user)
messages.success(request, f"Welcome back, {user.first_name}!")

# Validate next parameter to prevent open redirects
next_url = request.GET.get("next", "index")
if url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure()
):
return redirect(next_url)
return redirect("index")
else:
form = LoginForm()

Expand Down
Loading