Files
bindery/bindery/config.py
T
tim 5f682d209b Remove small duplicates in the settings, the logins and the names
One function reads a true or false value. The download name uses the
file name cleaning of the ingest folder. The login code reads the login
mode from the configuration, and has one lockout message. The title
helper of FanFicFare is now unescape_title, because cwa.py has another
plain_title.
2026-10-03 08:09:41 +02:00

168 lines
7.4 KiB
Python

"""Configuration. Every setting comes from an environment variable."""
import logging
import os
import shutil
from pathlib import Path
logger = logging.getLogger(__name__)
def flag(value: str, default: bool = False) -> bool:
"""A text value as true or false. A value that is neither gives the default."""
value = value.strip().lower()
if value in ("true", "yes", "1", "y", "on"):
return True
if value in ("false", "no", "0", "n", "off"):
return False
return default
def _bool(name: str, default: bool = False) -> bool:
return flag(os.getenv(name, ""), default)
def _list(name: str, default: str = "") -> list[str]:
return [v.strip() for v in os.getenv(name, default).split(",") if v.strip()]
# A relative path is relative to the working folder. Paths are made absolute at once, because Flask's send_file
# reads a relative path from the package folder.
def _path(name: str, default: str) -> Path:
return Path(os.getenv(name, default)).absolute()
def _optional_path(name: str) -> Path | None:
value = os.getenv(name, "").strip()
return Path(value).absolute() if value else None
# A bad value stops the start with one clear line, not with a traceback.
def _number(name: str, default: str) -> float:
value = os.getenv(name, default).strip()
try:
return float(value)
except ValueError:
raise SystemExit(f"{name} must be a number, not {value!r}") from None
def _choice(name: str, default: str, choices: tuple[str, ...]) -> str:
value = os.getenv(name, default).strip().upper()
if value not in choices:
raise SystemExit(f"{name} must be one of {', '.join(choices)}, not {value!r}")
return value
def _base_path(value: str) -> str:
path = value.strip().strip("/")
return f"/{path}" if path else ""
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CONFIG_DIR = _path("CONFIG_DIR", "/config")
INGEST_DIR = _optional_path("INGEST_DIR")
TMP_DIR = _path("TMP_DIR", "/tmp/bindery")
LOG_LEVEL = _choice("LOG_LEVEL", "INFO", ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"))
# A path prefix, such as /bindery, for a reverse proxy that forwards the full path.
URL_BASE = _base_path(os.getenv("URL_BASE", ""))
# The CWA account that Bindery uses for the OPDS feed.
CWA_URL = os.getenv("CWA_URL", "").strip()
CWA_USERNAME = os.getenv("CWA_USERNAME", "").strip()
CWA_PASSWORD = os.getenv("CWA_PASSWORD", "")
# Independent, optional read-only databases. HTTP remains the fallback.
CWA_DB_PATH = _optional_path("CWA_DB_PATH") # Calibre-Web app.db, for logins
CALIBRE_DB_PATH = _optional_path("CALIBRE_DB_PATH") # calibre metadata.db, for book IDs
# false if the user has no CWA. Bindery then ignores the CWA settings above, and shows nothing about CWA. INGEST_DIR
# still gets each built book, as a plain epub. Only a clear "false" turns CWA off.
CWA_ENABLED = _bool("CWA_ENABLED", True)
CWA_SETTINGS = ("CWA_URL", "CWA_USERNAME", "CWA_PASSWORD", "CWA_DB_PATH", "CALIBRE_DB_PATH")
IGNORED_SETTINGS = [] if CWA_ENABLED else [name for name in CWA_SETTINGS if os.getenv(name, "").strip()]
if not CWA_ENABLED:
CWA_URL = CWA_USERNAME = CWA_PASSWORD = ""
CWA_DB_PATH = CALIBRE_DB_PATH = None
UPDATE_INTERVAL_HOURS = _number("UPDATE_INTERVAL_HOURS", "24")
DEFAULT_LOCKED_FIELDS = _list("DEFAULT_LOCKED_FIELDS", "title")
# Logins: none, local, cwa or proxy. An API key works in every mode.
AUTH_METHOD = os.getenv("AUTH_METHOD", "none").strip().lower()
LOCAL_USERNAME = os.getenv("BINDERY_USERNAME", "").strip()
LOCAL_PASSWORD_HASH = os.getenv("BINDERY_PASSWORD_HASH", "").strip()
CWA_ALLOWED_USERS = _list("CWA_ALLOWED_USERS")
# The users of the cwa and proxy modes that can change the global settings. Without a list, each user can.
ADMIN_USERS = _list("BINDERY_ADMIN_USERS")
PROXY_AUTH_USER_HEADER = os.getenv("PROXY_AUTH_USER_HEADER", "X-Remote-User").strip()
PROXY_AUTH_LOGOUT_URL = os.getenv("PROXY_AUTH_LOGOUT_URL", "").strip()
# The addresses and networks that can send the user header. The default is each loopback and private address, so
# a request that comes straight from the internet cannot send it.
PROXY_AUTH_TRUSTED_IPS = _list(
"PROXY_AUTH_TRUSTED_IPS", "127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7, fe80::/10"
)
API_KEY = os.getenv("BINDERY_API_KEY", "").strip()
# The host names that Bindery answers to. A leading dot also allows the subdomains, and * allows every name.
# Without a list, the "none" login mode answers only to addresses and local names. The other modes answer to all.
ALLOWED_HOSTS = [h.lower() for h in _list("ALLOWED_HOSTS")]
SESSION_COOKIE_SECURE = _bool("SESSION_COOKIE_SECURE")
# Take the client address from the last entry of X-Forwarded-For, which a reverse proxy adds.
TRUST_PROXY = _bool("TRUST_PROXY")
# Tests turn off the job threads.
START_THREADS = _bool("BINDERY_START_THREADS", True)
SITE_DELAY = 5 # seconds between two jobs that go to a story site
JOB_LIST_MAX_HOURS = 24 * 30 # the highest allowed value of a "jobs from the last N hours" setting
JOB_LIST_MAX_COUNT = 500 # the highest allowed value of a "to a maximum of N jobs" setting
JOB_HISTORY = JOB_LIST_MAX_HOURS * 60 * 60 # seconds; finished jobs stay this long, for the settings above
DB_PATH = CONFIG_DIR / "bindery.db"
UPLOAD_DIR = CONFIG_DIR / "uploads" # uploaded epubs wait here for their job, also over a restart
SECRET_KEY_FILE = CONFIG_DIR / ".flask_secret"
PERSONAL_INI = CONFIG_DIR / "personal.ini"
DEFAULT_PERSONAL_INI = PROJECT_ROOT / "data" / "personal.ini"
BACKUP_DIR = CONFIG_DIR / "backups"
BACKUP_COUNT = 7 # daily backups to keep
def prepare() -> None:
"""Make the folders, and give the user a personal.ini to edit on first start."""
for directory in (CONFIG_DIR, UPLOAD_DIR, TMP_DIR):
directory.mkdir(parents=True, exist_ok=True)
# The ingest folder belongs to CWA. A folder that Bindery makes hides a missing volume mount, and CWA
# never sees its files.
if INGEST_DIR and not INGEST_DIR.is_dir():
logger.error(
f"The ingest folder {INGEST_DIR} does not exist. Check INGEST_DIR and the volume mount. "
"Uploads fail until the folder exists."
)
if not PERSONAL_INI.exists():
shutil.copy(DEFAULT_PERSONAL_INI, PERSONAL_INI)
logger.info(f"Created the default FanFicFare configuration at {PERSONAL_INI}")
if not CWA_ENABLED:
if IGNORED_SETTINGS:
logger.info(f"CWA_ENABLED is false. Bindery ignores {', '.join(IGNORED_SETTINGS)}.")
return
if CWA_PASSWORD and not CWA_USERNAME:
logger.warning("CWA_PASSWORD is set, but CWA_USERNAME is not. Bindery sends no login to CWA.")
missing = _missing_cwa()
for warning in missing:
logger.warning(warning)
if missing:
logger.warning("If you do not use CWA, set CWA_ENABLED=false. Bindery then shows no messages about CWA.")
def _missing_cwa() -> list[str]:
"""What Bindery cannot do with CWA, because a setting is not set."""
missing = []
if INGEST_DIR is None:
missing.append("INGEST_DIR is not set. Bindery sends no book to CWA.")
if not (CWA_URL or CALIBRE_DB_PATH):
effect = ", so each update adds a new book to CWA." if INGEST_DIR else "."
missing.append(
"CWA_URL and CALIBRE_DB_PATH are not set. Bindery cannot find the library book of a story" + effect
)
elif not CWA_URL:
missing.append("CWA_URL is not set. Bindery cannot search the CWA library, or import books from it.")
return missing