221 lines
7.5 KiB
Python
221 lines
7.5 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
|
|
from app.core.pximg_reverse_proxy import DEFAULT_PXIMG_MIRROR_HOST, normalize_pximg_mirror_host
|
|
from app.core.logging import get_logger
|
|
from app.core.time import iso_utc_ms
|
|
from app.db.models.runtime_settings import RuntimeSetting
|
|
from app.db.session import create_sessionmaker, with_sqlite_busy_retry
|
|
|
|
log = get_logger(__name__)
|
|
|
|
|
|
def _as_bool(value: Any) -> bool | None:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, int) and value in (0, 1):
|
|
return bool(value)
|
|
if isinstance(value, str):
|
|
v = value.strip().lower()
|
|
if v in {"true", "1", "yes", "y", "on"}:
|
|
return True
|
|
if v in {"false", "0", "no", "n", "off"}:
|
|
return False
|
|
return None
|
|
|
|
|
|
def _as_str_list(value: Any) -> list[str]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for item in value:
|
|
if not isinstance(item, str):
|
|
continue
|
|
v = item.strip()
|
|
if not v or v in seen:
|
|
continue
|
|
seen.add(v)
|
|
out.append(v)
|
|
return out
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RuntimeConfig:
|
|
proxy_enabled: bool
|
|
proxy_fail_closed: bool
|
|
proxy_route_mode: str
|
|
proxy_allowlist_domains: list[str]
|
|
proxy_route_pools: dict[str, int]
|
|
proxy_default_pool_id: int | None
|
|
image_proxy_use_pixiv_cat: bool
|
|
image_proxy_pximg_mirror_host: str
|
|
random_defaults: dict[str, Any]
|
|
hide_origin_url_in_public_json: bool
|
|
rate_limit: dict[str, Any]
|
|
|
|
@classmethod
|
|
def defaults(cls) -> "RuntimeConfig":
|
|
return cls(
|
|
proxy_enabled=False,
|
|
proxy_fail_closed=True,
|
|
proxy_route_mode="pixiv_only",
|
|
proxy_allowlist_domains=[],
|
|
proxy_route_pools={},
|
|
proxy_default_pool_id=None,
|
|
image_proxy_use_pixiv_cat=False,
|
|
image_proxy_pximg_mirror_host=str(DEFAULT_PXIMG_MIRROR_HOST),
|
|
random_defaults={},
|
|
hide_origin_url_in_public_json=True,
|
|
rate_limit={},
|
|
)
|
|
|
|
|
|
async def fetch_runtime_settings(engine: AsyncEngine) -> dict[str, Any]:
|
|
Session = create_sessionmaker(engine)
|
|
|
|
async def _op() -> dict[str, Any]:
|
|
async with Session() as session:
|
|
result = await session.execute(select(RuntimeSetting.key, RuntimeSetting.value_json))
|
|
values: dict[str, Any] = {}
|
|
for key, value_json in result.all():
|
|
try:
|
|
values[str(key)] = json.loads(value_json)
|
|
except Exception:
|
|
log.warning("runtime_settings_invalid_json key=%s", key)
|
|
return values
|
|
|
|
return await with_sqlite_busy_retry(_op)
|
|
|
|
|
|
def runtime_config_from_values(values: dict[str, Any]) -> RuntimeConfig:
|
|
defaults = RuntimeConfig.defaults()
|
|
|
|
proxy_enabled = _as_bool(values.get("proxy.enabled"))
|
|
proxy_fail_closed = _as_bool(values.get("proxy.fail_closed"))
|
|
|
|
proxy_route_mode_raw = values.get("proxy.route_mode")
|
|
proxy_route_mode = defaults.proxy_route_mode
|
|
if isinstance(proxy_route_mode_raw, str):
|
|
candidate = proxy_route_mode_raw.strip().lower()
|
|
if candidate in {"pixiv_only", "all", "allowlist", "off"}:
|
|
proxy_route_mode = candidate
|
|
|
|
proxy_allowlist_domains = _as_str_list(values.get("proxy.allowlist_domains")) or defaults.proxy_allowlist_domains
|
|
|
|
proxy_route_pools_raw = values.get("proxy.route_pools")
|
|
proxy_route_pools: dict[str, int] = {}
|
|
if isinstance(proxy_route_pools_raw, dict):
|
|
for k, v in proxy_route_pools_raw.items():
|
|
key = str(k or "").strip().lower().strip(".")
|
|
if not key or len(key) > 200:
|
|
continue
|
|
try:
|
|
pool_id = int(v)
|
|
except Exception:
|
|
continue
|
|
if pool_id <= 0:
|
|
continue
|
|
proxy_route_pools[key] = pool_id
|
|
|
|
proxy_default_pool_id: int | None = None
|
|
default_pool_raw = values.get("proxy.default_pool_id")
|
|
if default_pool_raw is not None:
|
|
try:
|
|
candidate = int(default_pool_raw)
|
|
except Exception:
|
|
candidate = 0
|
|
proxy_default_pool_id = candidate if candidate > 0 else None
|
|
|
|
image_proxy_use_pixiv_cat_raw = values.get("image_proxy.use_pixiv_cat")
|
|
image_proxy_use_pixiv_cat = _as_bool(image_proxy_use_pixiv_cat_raw)
|
|
|
|
mirror_host_raw = values.get("image_proxy.pximg_mirror_host")
|
|
mirror_host = normalize_pximg_mirror_host(mirror_host_raw)
|
|
|
|
random_defaults_raw = values.get("random.defaults")
|
|
random_defaults = defaults.random_defaults
|
|
if isinstance(random_defaults_raw, dict):
|
|
random_defaults = dict(random_defaults_raw)
|
|
|
|
hide_origin_url_raw = values.get("security.hide_origin_url_in_public_json")
|
|
hide_origin_url = _as_bool(hide_origin_url_raw)
|
|
|
|
rate_limit: dict[str, Any] = {}
|
|
for key, value in values.items():
|
|
if key.startswith("rate_limit."):
|
|
rate_limit[key.removeprefix("rate_limit.")] = value
|
|
|
|
return RuntimeConfig(
|
|
proxy_enabled=proxy_enabled if proxy_enabled is not None else defaults.proxy_enabled,
|
|
proxy_fail_closed=proxy_fail_closed if proxy_fail_closed is not None else defaults.proxy_fail_closed,
|
|
proxy_route_mode=proxy_route_mode,
|
|
proxy_allowlist_domains=proxy_allowlist_domains,
|
|
proxy_route_pools=proxy_route_pools,
|
|
proxy_default_pool_id=proxy_default_pool_id,
|
|
image_proxy_use_pixiv_cat=image_proxy_use_pixiv_cat
|
|
if image_proxy_use_pixiv_cat is not None
|
|
else defaults.image_proxy_use_pixiv_cat,
|
|
image_proxy_pximg_mirror_host=str(mirror_host or defaults.image_proxy_pximg_mirror_host),
|
|
random_defaults=random_defaults,
|
|
hide_origin_url_in_public_json=hide_origin_url
|
|
if hide_origin_url is not None
|
|
else defaults.hide_origin_url_in_public_json,
|
|
rate_limit=rate_limit,
|
|
)
|
|
|
|
|
|
async def load_runtime_config(engine: AsyncEngine) -> RuntimeConfig:
|
|
values = await fetch_runtime_settings(engine)
|
|
return runtime_config_from_values(values)
|
|
|
|
|
|
async def set_runtime_setting(
|
|
engine: AsyncEngine,
|
|
*,
|
|
key: str,
|
|
value: Any,
|
|
description: str | None = None,
|
|
updated_by: str | None = None,
|
|
) -> None:
|
|
key = (key or "").strip()
|
|
if not key:
|
|
raise ValueError("key is required")
|
|
|
|
value_json = json.dumps(value, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
|
|
now = iso_utc_ms()
|
|
|
|
Session = create_sessionmaker(engine)
|
|
|
|
async def _op() -> None:
|
|
async with Session() as session:
|
|
stmt = sqlite_insert(RuntimeSetting).values(
|
|
key=key,
|
|
value_json=value_json,
|
|
description=description,
|
|
updated_at=now,
|
|
updated_by=updated_by,
|
|
)
|
|
set_values: dict[str, Any] = {
|
|
"value_json": value_json,
|
|
"updated_at": now,
|
|
"updated_by": updated_by,
|
|
}
|
|
if description is not None:
|
|
set_values["description"] = description
|
|
stmt = stmt.on_conflict_do_update(
|
|
index_elements=[RuntimeSetting.key],
|
|
set_=set_values,
|
|
)
|
|
await session.execute(stmt)
|
|
await session.commit()
|
|
|
|
await with_sqlite_busy_retry(_op)
|