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.
168 lines
7.4 KiB
Python
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
|