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.
335 lines
10 KiB
Python
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",
|
|
},
|
|
}
|