Files
J621/backend/config/settings.py
T
JakeBreath bd2417aff8 Keep a broken Redis from 500ing the whole API
Redis backs the DRF throttles, and the stock RedisCache raises inside the
throttle check when Redis is unreachable or refusing writes (a failed RDB
snapshot disables writes by default) — turning a cache problem into a
blanket 500, which is exactly what took prod down. ResilientRedisCache
treats backend failures as cache misses, logs the first one per worker, and
lets rate limits degrade until Redis is back.
2026-09-20 21:08:38 -05:00

335 lines
10 KiB
Python

"""
Django settings for the J621 backend.
"""
import os
import subprocess
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
SECRET_KEY = os.getenv("SECRET_KEY", "django-insecure-dev-only-change-me")
DEBUG = os.getenv("DEBUG", "True").lower() == "true"
if not DEBUG and SECRET_KEY == "django-insecure-dev-only-change-me":
raise ImproperlyConfigured(
"SECRET_KEY is still the development default. Set a long random value "
"in backend/.env before running with DEBUG=False — it signs the media "
"URLs and encrypts stored e621 keys."
)
ALLOWED_HOSTS = [
host.strip()
for host in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")
if host.strip()
]
# Same-origin access always works; list extra frontend origins in
# CORS_ALLOWED_ORIGINS (comma separated, e.g. https://j621.example.com).
CORS_ALLOWED_ORIGINS = [
origin.strip().rstrip("/")
for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
if origin.strip()
]
# Escape hatch for local experiments; never enable against a public server.
CORS_ALLOW_ALL_ORIGINS = (
os.getenv("CORS_ALLOW_ALL_ORIGINS", "false").lower() == "true"
)
# The SPA authenticates with an Authorization: Token header, not cookies, so
# cross-origin requests do not need credentials. Enable this only if a
# cross-origin client really relies on cookies (e.g. the Django admin).
CORS_ALLOW_CREDENTIALS = (
os.getenv("CORS_ALLOW_CREDENTIALS", "false").lower() == "true"
)
# Same list, for session/CSRF-protected endpoints (Django admin) reached from
# another origin.
CSRF_TRUSTED_ORIGINS = [
origin.strip().rstrip("/")
for origin in os.getenv("CSRF_TRUSTED_ORIGINS", "").split(",")
if origin.strip()
]
# Behind a TLS-terminating proxy the backend sees plain http; trust the
# proxy's X-Forwarded-Proto/Host so absolute media URLs keep https and the
# public hostname.
if os.getenv("TRUST_PROXY_HEADERS", "false").lower() == "true":
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
def _git_commit_hash():
"""Short git hash of the running build, mirroring the original app.
Containers rarely ship the .git directory, so an explicit GIT_COMMIT_HASH
environment variable (baked in at image build time) wins when present.
"""
configured = os.getenv("GIT_COMMIT_HASH", "").strip()
if configured:
return configured[:12]
try:
return subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
cwd=BASE_DIR,
text=True,
stderr=subprocess.DEVNULL,
).strip()
except (subprocess.SubprocessError, OSError):
return "unknown"
GIT_COMMIT_HASH = _git_commit_hash()
APP_ENV = "dev" if DEBUG else "prod"
APP_VERSION = f"{APP_ENV} @ {GIT_COMMIT_HASH}"
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework.authtoken",
"django_filters",
"corsheaders",
"apps.accounts",
"apps.library",
"apps.follows",
"apps.core",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
# Serves the Django admin's static files in production (collected at
# image build time); harmless in dev.
"whitenoise.middleware.WhiteNoiseMiddleware",
# Must sit above CommonMiddleware so preflights are answered early.
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"apps.core.middleware.TimingMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
# Database (MariaDB, run via docker compose at the repo root)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": os.getenv("DB_NAME", "j621"),
"USER": os.getenv("DB_USER", "j621"),
"PASSWORD": os.getenv("DB_PASSWORD", "j621"),
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
"PORT": os.getenv("DB_PORT", "3307"),
"OPTIONS": {"charset": "utf8mb4"},
}
}
# Authentication
AUTH_USER_MODEL = "accounts.User"
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
LANGUAGE_CODE = "en-us"
TIME_ZONE = os.getenv("TIME_ZONE", "America/Bogota")
USE_I18N = True
USE_TZ = True
# Static and media files
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
# No manifest: works whether or not collectstatic ran (dev included).
"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
},
}
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# Watched folder that gets indexed into the library.
_watched = os.getenv("WATCHED_FOLDER", "").strip()
WATCHED_FOLDER = Path(_watched) if _watched else MEDIA_ROOT / "library"
if not WATCHED_FOLDER.is_absolute():
WATCHED_FOLDER = BASE_DIR / WATCHED_FOLDER
WATCHED_FOLDER = str(WATCHED_FOLDER)
# e621 integration
E621_BASE_URL = os.getenv("E621_BASE_URL", "https://e621.net").rstrip("/")
# e621 asks for "Application name/version (developer)". Browser clients cannot
# set a User-Agent, so the SPA sends the same string in its `_client` parameter.
USER_AGENT = os.getenv("USER_AGENT", f"J621/{GIT_COMMIT_HASH} (JakeBreath)")
# Hosts the client-download proxy is allowed to stream from.
E621_MEDIA_HOSTS = [
host.strip()
for host in os.getenv(
"E621_MEDIA_HOSTS",
"static1.e621.net,static2.e621.net,static3.e621.net",
).split(",")
if host.strip()
]
# Guest visibility: e621's anonymous default blacklist is mirrored into the
# cache by `manage.py refresh_guest_blacklist`. This fallback is used until
# that command runs or when e621 is unreachable.
GUEST_BLACKLIST_FALLBACK = [
tag.strip()
for tag in os.getenv(
"GUEST_BLACKLIST_FALLBACK", "young,cub,shota,loli,child,underage"
).split(",")
if tag.strip()
]
GUEST_BLACKLIST_TTL = int(os.getenv("GUEST_BLACKLIST_TTL", "3600"))
# Similarity threshold for flagging staged uploads that match library items.
VISUAL_MATCH_THRESHOLD = float(os.getenv("VISUAL_MATCH_THRESHOLD", "0.9"))
# Ephemeral similarity-check uploads are deleted after this many minutes
# (and always on startup).
SIMILARITY_TTL_MINUTES = int(os.getenv("SIMILARITY_TTL_MINUTES", "30"))
# Redis cache (run via docker compose at the repo root), shared by web
# workers and management commands (e.g. the mirrored guest blacklist).
CACHES = {
"default": {
"BACKEND": "apps.core.cache.ResilientRedisCache",
"LOCATION": os.getenv("REDIS_URL", "redis://127.0.0.1:6380/1"),
}
}
# Django REST Framework
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
"DEFAULT_PAGINATION_CLASS": "config.pagination.StandardPagination",
"PAGE_SIZE": 48,
# Per-IP/per-user rate limits (counted in the shared Redis cache).
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
# Generous enough for the shell polling (status every 5s, stats every 2s).
"anon": os.getenv("THROTTLE_ANON", "120/min"),
"user": os.getenv("THROTTLE_USER", "600/min"),
# Credential stuffing / spam guards.
"login": os.getenv("THROTTLE_LOGIN", "5/min"),
"register": os.getenv("THROTTLE_REGISTER", "20/hour"),
# The guest e621 download proxy streams whole files.
"e621_proxy": os.getenv("THROTTLE_E621_PROXY", "60/hour"),
},
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGS_DIR = BASE_DIR / "logs"
LOG_FILE = LOGS_DIR / "j621.log"
try:
LOGS_DIR.mkdir(parents=True, exist_ok=True)
except OSError: # read-only checkout: keep console logging only
pass
_log_handlers = {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
}
_root_handlers = ["console"]
if LOGS_DIR.exists():
_log_handlers["file"] = {
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_FILE),
"maxBytes": 5 * 1024 * 1024,
"backupCount": 3,
"encoding": "utf-8",
"formatter": "simple",
}
_root_handlers.append("file")
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "{levelname} {asctime} {name} {message}",
"style": "{",
},
},
"handlers": _log_handlers,
"loggers": {
# Scanners on the network probe things like /health and /v1/models;
# their 404s are noise in the log file. Real server errors (5xx) still
# log, and the dev server keeps printing every request to the console.
"django.request": {
"handlers": _root_handlers,
"level": "ERROR",
"propagate": False,
},
},
"root": {
"handlers": _root_handlers,
"level": "INFO",
},
}