core: Replace legacy INI config system with Pydantic-based TOML configuration.

This is a huge configuration refactoring.

The configuration system now uses Pydantic settings, with better validation, better
support for environment variables and other goodies.

The main config files now use TOML instead of INI, this allow for better handling of
complex types, notably dict.

Plugins, components and frontends can declare their config settings in a class which will
be imported dynamically.
This commit is contained in:
Goffi 2025-11-30 12:27:35 +01:00
commit 6a7b7bc36a

View file

@ -21,29 +21,33 @@ import os.path
import mimetypes
import tempfile
from functools import partial
from typing import Any
from pydantic import Field, field_validator
import shortuuid
import unicodedata
from urllib.parse import urljoin, urlparse, quote, unquote
from pathlib import Path
from libervia.backend import G
from libervia.backend.core.i18n import _, D_
from libervia.backend.core.constants import Const as C
from libervia.backend.core import exceptions
from libervia.backend.core.log import getLogger
from libervia.backend.tools import stream
from libervia.backend.tools import video
from libervia.backend.tools.config import ComponentSettings, ConfigOptions, TLSSettings
from libervia.backend.tools.utils import ensure_deferred
from libervia.backend.tools.common import regex
from libervia.backend.tools.common import uri
from libervia.backend.tools.common import files_utils
from libervia.backend.tools.common import utils
from libervia.backend.tools.common import tls
from libervia.backend.models.types import JIDType
from twisted.internet import defer, reactor
from twisted.words.protocols.jabber import error
from twisted.words.protocols.jabber import error, jid
from twisted.web import server, resource, static, http
from wokkel import pubsub
from wokkel import generic
log = getLogger(__name__)
@ -85,6 +89,66 @@ OVER_QUOTA_TXT = D_(
HTTP_VERSION = unicodedata.normalize("NFKD", f"{C.APP_NAME} file sharing {C.APP_VERSION}")
class QuotasSettings(ConfigOptions):
"""Configuration settings for file sharing quotas.
All quota values should be specified in bytes or using human-readable format
like "100MB", "2GB", etc.
"""
admins: int | None = Field(
default=None,
description="Quota for administrators. If no set, no limit applies.",
)
users: int | None = Field(
default=None,
description="Default quota for regular users. If not set, no limit applies.",
)
jids: dict[JIDType, int] = Field(
default_factory=dict,
description="Mapping of specific JIDs to their quotas.",
)
@field_validator(
"admins", "users", mode="before", json_schema_input_type=int | str | None
)
@classmethod
def parse_quotas(cls, value: Any) -> Any:
"""Parse string sizes to int"""
if isinstance(value, str):
return utils.parse_size(value)
return value
@field_validator(
"jids", mode="before", json_schema_input_type=dict[str, int | str]
)
@classmethod
def parse_jid_quotas(cls, value: Any) -> Any:
"""Parse string sizes to int for ``jids``."""
if isinstance(value, dict):
for k, v in value.items():
if isinstance(v, str):
value[k] = utils.parse_size(v)
return value
class FileSharingSettings(ComponentSettings, plugin_info=PLUGIN_INFO):
public_url: str | None = Field(default=None, description="")
http_upload_port: int = Field(default=8888, description="Port for HTTP file upload")
http_upload_connection_type: str = Field(
default="https", description="Connection type for HTTP upload (http or https)"
)
quotas: QuotasSettings = QuotasSettings()
http_upload_allowed_hosts: list[str] = Field(
default_factory=list,
description=(
"List of XMPP domain which can upload on this component. Default to"
" component's host."
),
)
tls: TLSSettings | None = None
class HTTPFileServer(resource.Resource):
isLeaf = True
@ -310,8 +374,9 @@ class FileSharingRequest(server.Request):
if upload_request.filename != filename:
log.warning(
f"invalid filename for PUT (upload id: {upload_id!r}, URL: {self.channel._path.decode()}). Original "
f"{upload_request.filename!r} doesn't match {filename!r}"
f"invalid filename for PUT (upload id: {upload_id!r}, URL:"
f" {self.channel._path.decode()}). Original"
f" {upload_request.filename!r} doesn't match {filename!r}"
)
return self.refuse_request()
@ -356,6 +421,7 @@ class FileSharing:
if self.initialised:
return
self.initialised = True
self.settings = G.config.get_settings(FileSharingSettings)
log.info(_("File Sharing initialization"))
self._f = self.host.plugins["FILE"]
self._jf = self.host.plugins["XEP-0234"]
@ -390,14 +456,8 @@ class FileSharing:
"XEP-0329_parseResult_directory", self, self._get_directory_metadata_elts
)
self.files_path = self.host.get_local_path(None, C.FILES_DIR)
self.http_port = int(
self.host.memory.config_get(
"component file-sharing", "http_upload_port", 8888
)
)
connection_type = self.host.memory.config_get(
"component file-sharing", "http_upload_connection_type", "https"
)
self.http_port = self.settings.http_upload_port
connection_type = self.settings.http_upload_connection_type
if connection_type not in ("http", "https"):
raise exceptions.ConfigError(
'bad http_upload_connection_type, you must use one of "http" or "https"'
@ -407,9 +467,7 @@ class FileSharing:
if connection_type == "http":
reactor.listenTCP(self.http_port, self.server)
else:
options = tls.get_options_from_config(
self.host.memory.config, "component file-sharing"
)
options = tls.get_options_from_config(self.settings.tls)
tls.tls_options_check(options)
context_factory = tls.get_tls_context_factory(options)
reactor.listenSSL(self.http_port, self.server, context_factory)
@ -422,32 +480,32 @@ class FileSharing:
client.enabled_features.add("XEP-0363")
self.init()
public_base_url = self.host.memory.config_get(
"component file-sharing", "http_upload_public_facing_url"
)
public_base_url = self.settings.public_url
if public_base_url is None:
client._file_sharing_base_url = f"https://{client.host}:{self.http_port}"
else:
client._file_sharing_base_url = public_base_url
path = client.file_tmp_dir = os.path.join(
self.host.memory.config_get("", "local_dir"),
G.config.common.local_dir,
C.FILES_TMP_DIR,
regex.path_escape(client.profile),
)
if not os.path.exists(path):
os.makedirs(path)
def get_quota(self, client, entity):
"""Return maximum size allowed for all files for entity"""
quotas = self.host.memory.config_get("component file-sharing", "quotas_json", {})
if self.host.memory.is_admin_jid(entity):
quota = quotas.get("admins")
else:
try:
quota = quotas["jids"][entity.userhost()]
except KeyError:
quota = quotas.get("users")
return None if quota is None else utils.parse_size(quota)
def get_quota(self, entity: jid.JID) -> int | None:
"""Get the maximum file storage quota for a given entity.
@param entity: The JID to check quota for.
@return: The maximum allowed size in bytes, or None if no quota is set.
"""
entity_bare = entity.userhostJID()
quotas = self.settings.quotas
quota = quotas.jids.get(entity_bare)
if not quota:
is_admin = G.host.memory.is_admin_jid(entity_bare)
quota = quotas.admins if is_admin else quotas.users
return quota
async def generate_thumbnails(self, extra: dict, image_path: Path):
thumbnails = extra.setdefault(C.KEY_THUMBNAILS, [])
@ -552,7 +610,7 @@ class FileSharing:
assert C.KEY_PROGRESS_ID in file_data
filename = file_data["name"]
assert filename and not "/" in filename
quota = self.get_quota(client, peer_jid)
quota = self.get_quota(peer_jid)
if quota is not None:
used_space = await self.host.memory.file_get_used_space(client, peer_jid)
@ -676,7 +734,7 @@ class FileSharing:
if request.from_.host not in client._file_sharing_allowed_hosts:
raise error.StanzaError("forbidden")
quota = self.get_quota(client, request.from_)
quota = self.get_quota(request.from_)
if quota is not None:
used_space = await self.host.memory.file_get_used_space(client, request.from_)
@ -786,7 +844,9 @@ class Comments_handler(pubsub.PubSubService):
self.plugin_parent = plugin_parent
self.discoIdentity = {
"category": "pubsub",
"type": "virtual", # FIXME: non standard, here to avoid this service being considered as main pubsub one
"type": (
"virtual"
), # FIXME: non standard, here to avoid this service being considered as main pubsub one
"name": "files commenting service",
}