The dev Vite proxy rewrites the request Host to 127.0.0.1:8000, so the
backend's absolute signed file URLs pointed at a different origin than the
SPA (localhost:5173). Images tolerated it, but the auth'd fetch that reads
the staging blob for IQDB was blocked ('Cross-Origin Request Blocked') and
every similarity check died before reaching e621.
apiUrl() now keeps API-built absolute URLs on the page's origin whenever the
SPA is in same-origin mode (dev proxy, deploy nginx) and leaves them
absolute when an explicit backend URL is configured. All consumers use it:
staging previews and the bulk modal, library cards, optimizer (range sniff +
worker), IQDB card, delete page, similar page.
e621 identification now follows the documented 'App/version (developer)'
form: server-side requests send 'J621/<hash> (JakeBreath)' and the browser
_client gets the same string, with the hash baked into the frontend image
(GIT_HASH build arg; guarded at runtime so the dev server still works).
IQDB stalls: requests now time out after 20s (a hung fetch used to block the
serialized e621 queue forever), and all checks run through one serial drain
so repeated 'Check similarity' clicks can no longer start overlapping runs
that re-download the same staging blobs. Auth/rate-limit/timeout/network
failures stop the queue with the reason and a retry button instead of
grinding through the rest.
Verified live: staged file URL is same-origin through the proxy and fetches
200 through it.
335 lines
11 KiB
Python
335 lines
11 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": "django.core.cache.backends.redis.RedisCache",
|
|
"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",
|
|
},
|
|
}
|