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:
parent
f3f40848b3
commit
6a7b7bc36a
1 changed files with 1392 additions and 1011 deletions
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue