Compare commits
16 Commits
stable.202
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
488dc9a3f3 | ||
|
|
1a65ba6150 | ||
|
|
9474238dee | ||
|
|
60e663316b | ||
|
|
770d05c72c | ||
|
|
ea75d8e832 | ||
|
|
145dda8c19 | ||
|
|
149a79cb2c | ||
|
|
69f77020b8 | ||
|
|
5ac481a4b4 | ||
|
|
56470eec01 | ||
|
|
9b4903b230 | ||
|
|
74c3946609 | ||
|
|
feabed6565 | ||
|
|
af13a3cc47 | ||
|
|
466e3e79b7 |
2
.github/workflows/main.yaml
vendored
@@ -48,7 +48,7 @@ jobs:
|
|||||||
pip install flake8 flake8-print
|
pip install flake8 flake8-print
|
||||||
- name: Linting
|
- name: Linting
|
||||||
run: |
|
run: |
|
||||||
python -m flake8 snikket_web
|
make flake8
|
||||||
|
|
||||||
translation-check:
|
translation-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
18
Makefile
@@ -5,6 +5,8 @@ generated_css_files = $(patsubst snikket_web/scss/%.scss,snikket_web/static/css/
|
|||||||
translation_basepath = snikket_web/translations
|
translation_basepath = snikket_web/translations
|
||||||
pot_file = $(translation_basepath)/messages.pot
|
pot_file = $(translation_basepath)/messages.pot
|
||||||
|
|
||||||
|
black_formatted_py = snikket_web/prosodyclient.py
|
||||||
|
|
||||||
PYTHON3 ?= python3
|
PYTHON3 ?= python3
|
||||||
SCSSC ?= sassc --load-path snikket_web/scss/
|
SCSSC ?= sassc --load-path snikket_web/scss/
|
||||||
|
|
||||||
@@ -34,4 +36,20 @@ compile_translations:
|
|||||||
-pybabel compile -d $(translation_basepath)
|
-pybabel compile -d $(translation_basepath)
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: lint
|
||||||
|
lint: format flake8
|
||||||
|
|
||||||
|
.PHONY: format
|
||||||
|
format:
|
||||||
|
$(PYTHON3) -m black $(black_formatted_py)
|
||||||
|
|
||||||
|
.PHONY: flake8
|
||||||
|
flake8:
|
||||||
|
$(PYTHON3) -m flake8 --exclude=$(subst $(space),$(comma),$(strip $(black_formatted_py))) snikket_web
|
||||||
|
$(PYTHON3) -m flake8 --ignore=E501,W503 $(black_formatted_py)
|
||||||
|
|
||||||
|
.PHONY: mypy
|
||||||
|
mypy:
|
||||||
|
$(PYTHON3) -m mypy --python-version 3.11 snikket_web
|
||||||
|
|
||||||
.PHONY: build_css clean update_translations compile_translations extract_translations force_update_translations
|
.PHONY: build_css clean update_translations compile_translations extract_translations force_update_translations
|
||||||
|
|||||||
@@ -47,10 +47,20 @@ def apple_store_badge() -> str:
|
|||||||
return url_for("static", filename="img/apple/en.svg")
|
return url_for("static", filename="img/apple/en.svg")
|
||||||
|
|
||||||
|
|
||||||
|
def play_store_badge() -> str:
|
||||||
|
locale = selected_locale()
|
||||||
|
filename = "{}_badge_web_generic.png".format(locale)
|
||||||
|
static_path = pathlib.Path(__file__).parent / "static" / "img" / "google"
|
||||||
|
if (static_path / filename).exists():
|
||||||
|
return url_for("static", filename="img/google/{}".format(filename))
|
||||||
|
return url_for("static", filename="img/google/en_badge_web_generic.png")
|
||||||
|
|
||||||
|
|
||||||
@bp.context_processor
|
@bp.context_processor
|
||||||
def context() -> typing.Dict[str, typing.Any]:
|
def context() -> typing.Dict[str, typing.Any]:
|
||||||
return {
|
return {
|
||||||
"apple_store_badge": apple_store_badge,
|
"apple_store_badge": apple_store_badge,
|
||||||
|
"play_store_badge": play_store_badge,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ import typing_extensions
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
from aiohttp import BasicAuth
|
||||||
|
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
from quart import (
|
from quart import (
|
||||||
current_app, session as http_session, abort, redirect,
|
current_app,
|
||||||
|
session as http_session,
|
||||||
|
abort,
|
||||||
|
redirect,
|
||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
import quart
|
import quart
|
||||||
@@ -25,7 +29,7 @@ from flask import g as _app_ctx_stack
|
|||||||
|
|
||||||
import werkzeug.exceptions
|
import werkzeug.exceptions
|
||||||
|
|
||||||
from . import xmpputil
|
from . import xmpputil, _version
|
||||||
from .xmpputil import split_jid
|
from .xmpputil import split_jid
|
||||||
|
|
||||||
|
|
||||||
@@ -56,14 +60,10 @@ class UserDeletionRequestInfo:
|
|||||||
if data is None:
|
if data is None:
|
||||||
return None
|
return None
|
||||||
return cls(
|
return cls(
|
||||||
deleted_at=datetime.fromtimestamp(
|
deleted_at=datetime.fromtimestamp(data["deleted_at"], tz=timezone.utc),
|
||||||
data["deleted_at"],
|
|
||||||
tz=timezone.utc
|
|
||||||
),
|
|
||||||
pending_until=datetime.fromtimestamp(
|
pending_until=datetime.fromtimestamp(
|
||||||
data["pending_until"],
|
data["pending_until"], tz=timezone.utc
|
||||||
tz=timezone.utc
|
),
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -221,9 +221,8 @@ class AdminGroupInfo:
|
|||||||
name=data["name"],
|
name=data["name"],
|
||||||
members=data.get("members", []),
|
members=data.get("members", []),
|
||||||
chats=[
|
chats=[
|
||||||
AdminGroupChatInfo.from_api_response(x)
|
AdminGroupChatInfo.from_api_response(x) for x in data.get("chats", [])
|
||||||
for x in data.get("chats", [])
|
],
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -252,10 +251,12 @@ class HTTPSessionManager:
|
|||||||
self._app_context_attribute = app_context_attribute
|
self._app_context_attribute = app_context_attribute
|
||||||
|
|
||||||
async def _create(self) -> aiohttp.ClientSession:
|
async def _create(self) -> aiohttp.ClientSession:
|
||||||
return aiohttp.ClientSession(headers={
|
return aiohttp.ClientSession(
|
||||||
|
headers={
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"Host": current_app.config["SNIKKET_DOMAIN"],
|
"Host": current_app.config["SNIKKET_DOMAIN"],
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def teardown(self, exc: typing.Optional[BaseException]) -> None:
|
async def teardown(self, exc: typing.Optional[BaseException]) -> None:
|
||||||
app_ctx = _app_ctx_stack
|
app_ctx = _app_ctx_stack
|
||||||
@@ -298,10 +299,7 @@ class HTTPSessionManager:
|
|||||||
|
|
||||||
|
|
||||||
class HTTPAuthSessionManager(HTTPSessionManager):
|
class HTTPAuthSessionManager(HTTPSessionManager):
|
||||||
def __init__(
|
def __init__(self, app_context_attribute: str, session_token_key: str):
|
||||||
self,
|
|
||||||
app_context_attribute: str,
|
|
||||||
session_token_key: str):
|
|
||||||
super().__init__(app_context_attribute)
|
super().__init__(app_context_attribute)
|
||||||
self._session_token_key = session_token_key
|
self._session_token_key = session_token_key
|
||||||
|
|
||||||
@@ -325,19 +323,20 @@ class AuthSessionProvider(typing_extensions.Protocol):
|
|||||||
|
|
||||||
|
|
||||||
def autosession(
|
def autosession(
|
||||||
f: typing.Callable[..., typing.Coroutine[typing.Any, typing.Any, T]]
|
f: typing.Callable[..., typing.Coroutine[typing.Any, typing.Any, T]],
|
||||||
) -> typing.Callable[...,
|
) -> typing.Callable[..., typing.Coroutine[typing.Any, typing.Any, T]]:
|
||||||
typing.Coroutine[typing.Any, typing.Any, T]]:
|
|
||||||
@functools.wraps(f)
|
@functools.wraps(f)
|
||||||
async def wrapper(
|
async def wrapper(
|
||||||
self: AuthSessionProvider,
|
self: AuthSessionProvider,
|
||||||
*args: typing.Any,
|
*args: typing.Any,
|
||||||
session: typing.Optional[aiohttp.ClientSession] = None,
|
session: typing.Optional[aiohttp.ClientSession] = None,
|
||||||
**kwargs: typing.Any) -> T:
|
**kwargs: typing.Any,
|
||||||
|
) -> T:
|
||||||
if session is None:
|
if session is None:
|
||||||
async with self._auth_session as session:
|
async with self._auth_session as session:
|
||||||
return (await f(self, *args, session=session, **kwargs))
|
return await f(self, *args, session=session, **kwargs)
|
||||||
return (await f(self, *args, session=session, **kwargs))
|
return await f(self, *args, session=session, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -352,15 +351,16 @@ class ProsodyClient:
|
|||||||
def __init__(self, app: typing.Optional[quart.Quart] = None):
|
def __init__(self, app: typing.Optional[quart.Quart] = None):
|
||||||
self._default_login_redirect: typing.Optional[str] = None
|
self._default_login_redirect: typing.Optional[str] = None
|
||||||
self._plain_session = HTTPSessionManager(self.CTX_PLAIN_SESSION)
|
self._plain_session = HTTPSessionManager(self.CTX_PLAIN_SESSION)
|
||||||
self._auth_session = HTTPAuthSessionManager(self.CTX_AUTH_SESSION,
|
self._auth_session = HTTPAuthSessionManager(
|
||||||
self.SESSION_TOKEN)
|
self.CTX_AUTH_SESSION, self.SESSION_TOKEN
|
||||||
self.logger = logging.getLogger(
|
|
||||||
".".join([__name__, type(self).__qualname__])
|
|
||||||
)
|
)
|
||||||
|
self.logger = logging.getLogger(".".join([__name__, type(self).__qualname__]))
|
||||||
self.app = app
|
self.app = app
|
||||||
if app is not None:
|
if app is not None:
|
||||||
self.init_app(app)
|
self.init_app(app)
|
||||||
|
|
||||||
|
self._client_info: typing.Optional[typing.Mapping[str, str]] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def default_login_redirect(self) -> typing.Optional[str]:
|
def default_login_redirect(self) -> typing.Optional[str]:
|
||||||
return self._default_login_redirect
|
return self._default_login_redirect
|
||||||
@@ -390,6 +390,10 @@ class ProsodyClient:
|
|||||||
def _rest_endpoint(self) -> str:
|
def _rest_endpoint(self) -> str:
|
||||||
return "{}/rest".format(self._endpoint_base)
|
return "{}/rest".format(self._endpoint_base)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _register_client_endpoint(self) -> str:
|
||||||
|
return "{}/oauth2/register".format(self._endpoint_base)
|
||||||
|
|
||||||
def _admin_v1_endpoint(self, subpath: str) -> str:
|
def _admin_v1_endpoint(self, subpath: str) -> str:
|
||||||
return "{}/admin_api{}".format(self._endpoint_base, subpath)
|
return "{}/admin_api{}".format(self._endpoint_base, subpath)
|
||||||
|
|
||||||
@@ -399,26 +403,30 @@ class ProsodyClient:
|
|||||||
def _xep227_endpoint(self, subpath: str) -> str:
|
def _xep227_endpoint(self, subpath: str) -> str:
|
||||||
return "{}/xep227{}".format(self._endpoint_base, subpath)
|
return "{}/xep227{}".format(self._endpoint_base, subpath)
|
||||||
|
|
||||||
async def _oauth2_bearer_token(self,
|
async def _oauth2_bearer_token(
|
||||||
session: aiohttp.ClientSession,
|
self, session: aiohttp.ClientSession, jid: str, password: str
|
||||||
jid: str,
|
) -> TokenInfo:
|
||||||
password: str) -> TokenInfo:
|
if not self.is_client_registered():
|
||||||
|
self.logger.debug("registering oauth client...")
|
||||||
|
await self.register_client()
|
||||||
|
self.logger.debug("registered client!")
|
||||||
request = aiohttp.FormData()
|
request = aiohttp.FormData()
|
||||||
request.add_field("grant_type", "password")
|
request.add_field("grant_type", "password")
|
||||||
request.add_field("username", jid)
|
request.add_field("username", jid.split("@")[0])
|
||||||
request.add_field("password", password)
|
request.add_field("password", password)
|
||||||
request.add_field(
|
request.add_field(
|
||||||
"scope",
|
"scope", " ".join([SCOPE_RESTRICTED, SCOPE_DEFAULT, SCOPE_ADMIN])
|
||||||
" ".join([SCOPE_RESTRICTED, SCOPE_DEFAULT, SCOPE_ADMIN])
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.logger.debug("sending OAuth2 request (payload omitted)")
|
self.logger.debug("sending OAuth2 request (payload omitted)")
|
||||||
async with session.post(self._login_endpoint, data=request) as resp:
|
async with session.post(
|
||||||
|
self._login_endpoint, auth=self.get_client_auth(), data=request
|
||||||
|
) as resp:
|
||||||
auth_status = resp.status
|
auth_status = resp.status
|
||||||
auth_info: typing.Mapping[str, str] = (await resp.json())
|
auth_info: typing.Mapping[str, str] = await resp.json()
|
||||||
|
|
||||||
if auth_status in [400, 401]:
|
if auth_status in [400, 401]:
|
||||||
self.logger.debug("oauth2 error: %r", auth_info)
|
self.logger.warning("oauth2 error: %r", auth_info)
|
||||||
# OAuth2 spec says that’s what can happen when some stuff is
|
# OAuth2 spec says that’s what can happen when some stuff is
|
||||||
# wrong.
|
# wrong.
|
||||||
# we have to interpret the JSON further
|
# we have to interpret the JSON further
|
||||||
@@ -430,9 +438,7 @@ class ProsodyClient:
|
|||||||
self.logger.debug("oauth2 success: token_type=%r", token_type)
|
self.logger.debug("oauth2 success: token_type=%r", token_type)
|
||||||
if token_type != "bearer":
|
if token_type != "bearer":
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"unsupported token type: {!r}".format(
|
"unsupported token type: {!r}".format(auth_info["token_type"])
|
||||||
auth_info["token_type"]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return TokenInfo(
|
return TokenInfo(
|
||||||
token=auth_info["access_token"],
|
token=auth_info["access_token"],
|
||||||
@@ -449,10 +455,61 @@ class ProsodyClient:
|
|||||||
http_session[self.SESSION_TOKEN] = token_info.token
|
http_session[self.SESSION_TOKEN] = token_info.token
|
||||||
http_session[self.SESSION_CACHED_SCOPE] = " ".join(token_info.scopes)
|
http_session[self.SESSION_CACHED_SCOPE] = " ".join(token_info.scopes)
|
||||||
|
|
||||||
|
def is_client_registered(self) -> bool:
|
||||||
|
return self._client_info is not None
|
||||||
|
|
||||||
|
async def register_client(self) -> None:
|
||||||
|
self.logger.debug(
|
||||||
|
"sending OAuth2 client registration request (payload omitted)"
|
||||||
|
)
|
||||||
|
registration_data = {
|
||||||
|
"client_name": "Snikket web portal",
|
||||||
|
"client_uri": "https://{}".format(current_app.config["SNIKKET_DOMAIN"]),
|
||||||
|
# This redirect URI is not used, because we use the password grant type.
|
||||||
|
# However, we're registering it with a sensible value because 1) Prosody
|
||||||
|
# requires us to provide at least one redirect_uri, and 2) if we ever
|
||||||
|
# need it in the future, we won't have to re-register.
|
||||||
|
"redirect_uris": [
|
||||||
|
"https://{}/login_result".format(current_app.config["SNIKKET_DOMAIN"])
|
||||||
|
],
|
||||||
|
"application_type": "web",
|
||||||
|
"grant_types": ["password"],
|
||||||
|
"response_types": [],
|
||||||
|
"token_endpoint_auth_method": "client_secret_post",
|
||||||
|
"scope": " ".join([SCOPE_RESTRICTED, SCOPE_DEFAULT, SCOPE_ADMIN]),
|
||||||
|
"software_id": "22aa246e-4373-51cb-bcaa-9f73bb235b84", # web-portal.snikket.org
|
||||||
|
"software_version": _version.version,
|
||||||
|
}
|
||||||
|
async with self._plain_session as session:
|
||||||
|
async with session.post(
|
||||||
|
self._register_client_endpoint, json=registration_data
|
||||||
|
) as resp:
|
||||||
|
reg_status = resp.status
|
||||||
|
|
||||||
|
if reg_status != 201:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Failed to register with backend server: ({}): {}",
|
||||||
|
reg_status,
|
||||||
|
await resp.text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._client_info = await resp.json()
|
||||||
|
|
||||||
|
def get_client_auth(self) -> BasicAuth:
|
||||||
|
if self._client_info is None:
|
||||||
|
raise RuntimeError("Client is not registered")
|
||||||
|
|
||||||
|
return BasicAuth(
|
||||||
|
login=self._client_info["client_id"],
|
||||||
|
password=self._client_info["client_secret"],
|
||||||
|
)
|
||||||
|
|
||||||
async def login(self, jid: str, password: str) -> bool:
|
async def login(self, jid: str, password: str) -> bool:
|
||||||
async with self._plain_session as session:
|
async with self._plain_session as session:
|
||||||
token_info = await self._oauth2_bearer_token(
|
token_info = await self._oauth2_bearer_token(
|
||||||
session, jid, password,
|
session,
|
||||||
|
jid,
|
||||||
|
password,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._store_token_in_session(token_info)
|
self._store_token_in_session(token_info)
|
||||||
@@ -485,12 +542,15 @@ class ProsodyClient:
|
|||||||
redirect_to: typing.Optional[str] = None,
|
redirect_to: typing.Optional[str] = None,
|
||||||
) -> typing.Callable[
|
) -> typing.Callable[
|
||||||
[typing.Callable[..., typing.Awaitable[T]]],
|
[typing.Callable[..., typing.Awaitable[T]]],
|
||||||
typing.Callable[..., typing.Awaitable[
|
typing.Callable[
|
||||||
typing.Union[T, quart.Response, werkzeug.Response]]]]:
|
..., typing.Awaitable[typing.Union[T, quart.Response, werkzeug.Response]]
|
||||||
|
],
|
||||||
|
]:
|
||||||
def decorator(
|
def decorator(
|
||||||
f: typing.Callable[..., typing.Awaitable[T]],
|
f: typing.Callable[..., typing.Awaitable[T]],
|
||||||
) -> typing.Callable[..., typing.Awaitable[
|
) -> typing.Callable[
|
||||||
typing.Union[T, quart.Response, werkzeug.Response]]]:
|
..., typing.Awaitable[typing.Union[T, quart.Response, werkzeug.Response]]
|
||||||
|
]:
|
||||||
@functools.wraps(f)
|
@functools.wraps(f)
|
||||||
async def wrapped(
|
async def wrapped(
|
||||||
*args: typing.Any,
|
*args: typing.Any,
|
||||||
@@ -499,14 +559,17 @@ class ProsodyClient:
|
|||||||
if not self.has_session or not (await self.test_session()):
|
if not self.has_session or not (await self.test_session()):
|
||||||
redirect_to_value = redirect_to
|
redirect_to_value = redirect_to
|
||||||
if redirect_to_value is not False:
|
if redirect_to_value is not False:
|
||||||
redirect_to_value = \
|
redirect_to_value = (
|
||||||
redirect_to_value or self._default_login_redirect
|
redirect_to_value or self._default_login_redirect
|
||||||
|
)
|
||||||
if not redirect_to_value:
|
if not redirect_to_value:
|
||||||
raise abort(401, "Not Authorized")
|
raise abort(401, "Not Authorized")
|
||||||
return redirect(url_for(redirect_to_value))
|
return redirect(url_for(redirect_to_value))
|
||||||
|
|
||||||
return await f(*args, **kwargs)
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
def require_admin_session(
|
def require_admin_session(
|
||||||
@@ -514,12 +577,15 @@ class ProsodyClient:
|
|||||||
redirect_to: typing.Optional[str] = None,
|
redirect_to: typing.Optional[str] = None,
|
||||||
) -> typing.Callable[
|
) -> typing.Callable[
|
||||||
[typing.Callable[..., typing.Awaitable[T]]],
|
[typing.Callable[..., typing.Awaitable[T]]],
|
||||||
typing.Callable[..., typing.Awaitable[
|
typing.Callable[
|
||||||
typing.Union[T, quart.Response, werkzeug.Response]]]]:
|
..., typing.Awaitable[typing.Union[T, quart.Response, werkzeug.Response]]
|
||||||
|
],
|
||||||
|
]:
|
||||||
def decorator(
|
def decorator(
|
||||||
f: typing.Callable[..., typing.Awaitable[T]],
|
f: typing.Callable[..., typing.Awaitable[T]],
|
||||||
) -> typing.Callable[..., typing.Awaitable[
|
) -> typing.Callable[
|
||||||
typing.Union[T, quart.Response, werkzeug.Response]]]:
|
..., typing.Awaitable[typing.Union[T, quart.Response, werkzeug.Response]]
|
||||||
|
]:
|
||||||
@functools.wraps(f)
|
@functools.wraps(f)
|
||||||
@self.require_session(redirect_to=redirect_to)
|
@self.require_session(redirect_to=redirect_to)
|
||||||
async def wrapped(
|
async def wrapped(
|
||||||
@@ -530,7 +596,9 @@ class ProsodyClient:
|
|||||||
raise abort(403, "This is not for you.")
|
raise abort(403, "This is not for you.")
|
||||||
|
|
||||||
return await f(*args, **kwargs)
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
async def _xml_iq_call(
|
async def _xml_iq_call(
|
||||||
@@ -544,10 +612,12 @@ class ProsodyClient:
|
|||||||
final_headers: typing.MutableMapping[str, str] = {}
|
final_headers: typing.MutableMapping[str, str] = {}
|
||||||
if headers is not None:
|
if headers is not None:
|
||||||
final_headers.update(headers)
|
final_headers.update(headers)
|
||||||
final_headers.update({
|
final_headers.update(
|
||||||
|
{
|
||||||
"Content-Type": "application/xmpp+xml",
|
"Content-Type": "application/xmpp+xml",
|
||||||
"Accept": "application/xmpp+xml",
|
"Accept": "application/xmpp+xml",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
if not payload.get("id"):
|
if not payload.get("id"):
|
||||||
payload.set("id", secrets.token_hex(8))
|
payload.set("id", secrets.token_hex(8))
|
||||||
|
|
||||||
@@ -555,15 +625,15 @@ class ProsodyClient:
|
|||||||
id_ = payload.get("id")
|
id_ = payload.get("id")
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"sending IQ (id=%s): %r",
|
"sending IQ (id=%s): %r",
|
||||||
id_, "(sensitive)" if sensitive else serialised,
|
id_,
|
||||||
|
"(sensitive)" if sensitive else serialised,
|
||||||
)
|
)
|
||||||
async with session.post(self._rest_endpoint,
|
async with session.post(
|
||||||
headers=final_headers,
|
self._rest_endpoint, headers=final_headers, data=serialised
|
||||||
data=serialised) as resp:
|
) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"IQ HTTP response (in-reply-to id=%s) with non-OK status "
|
"IQ HTTP response (in-reply-to id=%s) with non-OK status " "%s: %s",
|
||||||
"%s: %s",
|
|
||||||
id_,
|
id_,
|
||||||
resp.status,
|
resp.status,
|
||||||
resp.reason,
|
resp.reason,
|
||||||
@@ -572,7 +642,8 @@ class ProsodyClient:
|
|||||||
reply_payload = await resp.read()
|
reply_payload = await resp.read()
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"received IQ (in-reply-to id=%s): %r",
|
"received IQ (in-reply-to id=%s): %r",
|
||||||
id_, "(sensitive)" if sensitive else reply_payload,
|
id_,
|
||||||
|
"(sensitive)" if sensitive else reply_payload,
|
||||||
)
|
)
|
||||||
return ET.fromstring(reply_payload)
|
return ET.fromstring(reply_payload)
|
||||||
|
|
||||||
@@ -633,9 +704,9 @@ class ProsodyClient:
|
|||||||
return (await resp.json())["version"]["version"]
|
return (await resp.json())["version"]["version"]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"failed to parse prosody version from response"
|
"failed to parse prosody version from response" " (%s: %s)",
|
||||||
" (%s: %s)",
|
type(exc),
|
||||||
type(exc), exc,
|
exc,
|
||||||
)
|
)
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
@@ -646,8 +717,7 @@ class ProsodyClient:
|
|||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
) -> typing.Optional[str]:
|
) -> typing.Optional[str]:
|
||||||
iq_resp = await self._xml_iq_call(
|
iq_resp = await self._xml_iq_call(
|
||||||
session,
|
session, xmpputil.make_nickname_get_request(self.session_address)
|
||||||
xmpputil.make_nickname_get_request(self.session_address)
|
|
||||||
)
|
)
|
||||||
return xmpputil.extract_nickname_get_reply(iq_resp)
|
return xmpputil.extract_nickname_get_reply(iq_resp)
|
||||||
|
|
||||||
@@ -660,8 +730,7 @@ class ProsodyClient:
|
|||||||
) -> None:
|
) -> None:
|
||||||
iq_resp = await self._xml_iq_call(
|
iq_resp = await self._xml_iq_call(
|
||||||
session,
|
session,
|
||||||
xmpputil.make_nickname_set_request(self.session_address,
|
xmpputil.make_nickname_set_request(self.session_address, new_nickname),
|
||||||
new_nickname)
|
|
||||||
)
|
)
|
||||||
# just to throw errors
|
# just to throw errors
|
||||||
xmpputil.extract_iq_reply(iq_resp)
|
xmpputil.extract_iq_reply(iq_resp)
|
||||||
@@ -675,8 +744,7 @@ class ProsodyClient:
|
|||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
) -> typing.Mapping:
|
) -> typing.Mapping:
|
||||||
metadata_resp = await self._xml_iq_call(
|
metadata_resp = await self._xml_iq_call(
|
||||||
session,
|
session, xmpputil.make_avatar_metadata_request(from_)
|
||||||
xmpputil.make_avatar_metadata_request(from_)
|
|
||||||
)
|
)
|
||||||
info = xmpputil.extract_avatar_metadata_get_reply(metadata_resp)
|
info = xmpputil.extract_avatar_metadata_get_reply(metadata_resp)
|
||||||
if info is None:
|
if info is None:
|
||||||
@@ -684,7 +752,8 @@ class ProsodyClient:
|
|||||||
|
|
||||||
if not metadata_only:
|
if not metadata_only:
|
||||||
info["data"] = await self.get_avatar_data(
|
info["data"] = await self.get_avatar_data(
|
||||||
from_, info["sha1"],
|
from_,
|
||||||
|
info["sha1"],
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -699,19 +768,14 @@ class ProsodyClient:
|
|||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
) -> typing.Optional[bytes]:
|
) -> typing.Optional[bytes]:
|
||||||
data_resp = await self._xml_iq_call(
|
data_resp = await self._xml_iq_call(
|
||||||
session,
|
session, xmpputil.make_avatar_data_request(from_, id_)
|
||||||
xmpputil.make_avatar_data_request(from_, id_)
|
|
||||||
)
|
)
|
||||||
return xmpputil.extract_avatar_data_get_reply(data_resp)
|
return xmpputil.extract_avatar_data_get_reply(data_resp)
|
||||||
|
|
||||||
@autosession
|
@autosession
|
||||||
async def get_pubsub_node_access_model(
|
async def get_pubsub_node_access_model(
|
||||||
self,
|
self, to: str, node: str, default: str, *, session: aiohttp.ClientSession
|
||||||
to: str,
|
) -> str:
|
||||||
node: str,
|
|
||||||
default: str,
|
|
||||||
*,
|
|
||||||
session: aiohttp.ClientSession) -> str:
|
|
||||||
config = xmpputil.extract_pubsub_node_config_get_reply(
|
config = xmpputil.extract_pubsub_node_config_get_reply(
|
||||||
await self._xml_iq_call(
|
await self._xml_iq_call(
|
||||||
session,
|
session,
|
||||||
@@ -734,26 +798,26 @@ class ProsodyClient:
|
|||||||
new_access_model: str,
|
new_access_model: str,
|
||||||
*,
|
*,
|
||||||
ignore_not_found: bool = False,
|
ignore_not_found: bool = False,
|
||||||
session: aiohttp.ClientSession) -> None:
|
session: aiohttp.ClientSession,
|
||||||
|
) -> None:
|
||||||
try:
|
try:
|
||||||
xmpputil.extract_iq_reply(await self._xml_iq_call(
|
xmpputil.extract_iq_reply(
|
||||||
|
await self._xml_iq_call(
|
||||||
session,
|
session,
|
||||||
xmpputil.make_pubsub_access_model_put_request(
|
xmpputil.make_pubsub_access_model_put_request(
|
||||||
to,
|
to,
|
||||||
node,
|
node,
|
||||||
new_access_model,
|
new_access_model,
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
))
|
|
||||||
except werkzeug.exceptions.NotFound:
|
except werkzeug.exceptions.NotFound:
|
||||||
if ignore_not_found:
|
if ignore_not_found:
|
||||||
return
|
return
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@autosession
|
@autosession
|
||||||
async def get_nickname_access_model(
|
async def get_nickname_access_model(self, *, session: aiohttp.ClientSession) -> str:
|
||||||
self,
|
|
||||||
*,
|
|
||||||
session: aiohttp.ClientSession) -> str:
|
|
||||||
return await self.get_pubsub_node_access_model(
|
return await self.get_pubsub_node_access_model(
|
||||||
self.session_address,
|
self.session_address,
|
||||||
xmpputil.NODE_USER_NICKNAME,
|
xmpputil.NODE_USER_NICKNAME,
|
||||||
@@ -763,10 +827,8 @@ class ProsodyClient:
|
|||||||
|
|
||||||
@autosession
|
@autosession
|
||||||
async def set_nickname_access_model(
|
async def set_nickname_access_model(
|
||||||
self,
|
self, new_access_model: str, *, session: aiohttp.ClientSession
|
||||||
new_access_model: str,
|
) -> None:
|
||||||
*,
|
|
||||||
session: aiohttp.ClientSession) -> None:
|
|
||||||
await self.set_pubsub_node_access_model(
|
await self.set_pubsub_node_access_model(
|
||||||
self.session_address,
|
self.session_address,
|
||||||
xmpputil.NODE_USER_NICKNAME,
|
xmpputil.NODE_USER_NICKNAME,
|
||||||
@@ -776,10 +838,7 @@ class ProsodyClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@autosession
|
@autosession
|
||||||
async def get_avatar_access_model(
|
async def get_avatar_access_model(self, *, session: aiohttp.ClientSession) -> str:
|
||||||
self,
|
|
||||||
*,
|
|
||||||
session: aiohttp.ClientSession) -> str:
|
|
||||||
return await self.get_pubsub_node_access_model(
|
return await self.get_pubsub_node_access_model(
|
||||||
self.session_address,
|
self.session_address,
|
||||||
xmpputil.NODE_USER_AVATAR_METADATA,
|
xmpputil.NODE_USER_AVATAR_METADATA,
|
||||||
@@ -789,10 +848,8 @@ class ProsodyClient:
|
|||||||
|
|
||||||
@autosession
|
@autosession
|
||||||
async def set_avatar_access_model(
|
async def set_avatar_access_model(
|
||||||
self,
|
self, new_access_model: str, *, session: aiohttp.ClientSession
|
||||||
new_access_model: str,
|
) -> None:
|
||||||
*,
|
|
||||||
session: aiohttp.ClientSession) -> None:
|
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
self.set_pubsub_node_access_model(
|
self.set_pubsub_node_access_model(
|
||||||
self.session_address,
|
self.session_address,
|
||||||
@@ -807,14 +864,11 @@ class ProsodyClient:
|
|||||||
new_access_model,
|
new_access_model,
|
||||||
ignore_not_found=True,
|
ignore_not_found=True,
|
||||||
session=session,
|
session=session,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@autosession
|
@autosession
|
||||||
async def get_vcard_access_model(
|
async def get_vcard_access_model(self, *, session: aiohttp.ClientSession) -> str:
|
||||||
self,
|
|
||||||
*,
|
|
||||||
session: aiohttp.ClientSession) -> str:
|
|
||||||
return await self.get_pubsub_node_access_model(
|
return await self.get_pubsub_node_access_model(
|
||||||
self.session_address,
|
self.session_address,
|
||||||
xmpputil.NODE_VCARD,
|
xmpputil.NODE_VCARD,
|
||||||
@@ -824,10 +878,8 @@ class ProsodyClient:
|
|||||||
|
|
||||||
@autosession
|
@autosession
|
||||||
async def set_vcard_access_model(
|
async def set_vcard_access_model(
|
||||||
self,
|
self, new_access_model: str, *, session: aiohttp.ClientSession
|
||||||
new_access_model: str,
|
) -> None:
|
||||||
*,
|
|
||||||
session: aiohttp.ClientSession) -> None:
|
|
||||||
await self.set_pubsub_node_access_model(
|
await self.set_pubsub_node_access_model(
|
||||||
self.session_address,
|
self.session_address,
|
||||||
xmpputil.NODE_VCARD,
|
xmpputil.NODE_VCARD,
|
||||||
@@ -848,9 +900,7 @@ class ProsodyClient:
|
|||||||
|
|
||||||
data_resp = await self._xml_iq_call(
|
data_resp = await self._xml_iq_call(
|
||||||
session,
|
session,
|
||||||
xmpputil.make_avatar_data_set_request(self.session_address,
|
xmpputil.make_avatar_data_set_request(self.session_address, data, id_),
|
||||||
data,
|
|
||||||
id_)
|
|
||||||
)
|
)
|
||||||
xmpputil.extract_iq_reply(data_resp)
|
xmpputil.extract_iq_reply(data_resp)
|
||||||
|
|
||||||
@@ -863,7 +913,7 @@ class ProsodyClient:
|
|||||||
size=len(data),
|
size=len(data),
|
||||||
width=None,
|
width=None,
|
||||||
height=None,
|
height=None,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
xmpputil.extract_iq_reply(metadata_resp)
|
xmpputil.extract_iq_reply(metadata_resp)
|
||||||
|
|
||||||
@@ -880,7 +930,7 @@ class ProsodyClient:
|
|||||||
self.get_nickname_access_model(session=session),
|
self.get_nickname_access_model(session=session),
|
||||||
self.get_vcard_access_model(session=session),
|
self.get_vcard_access_model(session=session),
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
order = [
|
order = [
|
||||||
@@ -921,8 +971,7 @@ class ProsodyClient:
|
|||||||
password_changed = await self._xml_iq_call(
|
password_changed = await self._xml_iq_call(
|
||||||
session,
|
session,
|
||||||
xmpputil.make_password_change_request(
|
xmpputil.make_password_change_request(
|
||||||
self.session_address,
|
self.session_address, new_password
|
||||||
new_password
|
|
||||||
),
|
),
|
||||||
headers={
|
headers={
|
||||||
"Authorization": "Bearer {}".format(token_info.token),
|
"Authorization": "Bearer {}".format(token_info.token),
|
||||||
@@ -1057,8 +1106,7 @@ class ProsodyClient:
|
|||||||
) -> typing.Collection[AdminInviteInfo]:
|
) -> typing.Collection[AdminInviteInfo]:
|
||||||
async with session.get(self._admin_v1_endpoint("/invites")) as resp:
|
async with session.get(self._admin_v1_endpoint("/invites")) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
return list(map(AdminInviteInfo.from_api_response,
|
return list(map(AdminInviteInfo.from_api_response, await resp.json()))
|
||||||
await resp.json()))
|
|
||||||
|
|
||||||
@autosession
|
@autosession
|
||||||
async def get_invite_by_id(
|
async def get_invite_by_id(
|
||||||
@@ -1107,8 +1155,8 @@ class ProsodyClient:
|
|||||||
payload["note"] = note
|
payload["note"] = note
|
||||||
|
|
||||||
async with session.post(
|
async with session.post(
|
||||||
self._admin_v1_endpoint("/invites/account"),
|
self._admin_v1_endpoint("/invites/account"), json=payload
|
||||||
json=payload) as resp:
|
) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
return AdminInviteInfo.from_api_response(await resp.json())
|
return AdminInviteInfo.from_api_response(await resp.json())
|
||||||
|
|
||||||
@@ -1132,8 +1180,8 @@ class ProsodyClient:
|
|||||||
payload["note"] = note
|
payload["note"] = note
|
||||||
|
|
||||||
async with session.post(
|
async with session.post(
|
||||||
self._admin_v1_endpoint("/invites/group"),
|
self._admin_v1_endpoint("/invites/group"), json=payload
|
||||||
json=payload) as resp:
|
) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
return AdminInviteInfo.from_api_response(await resp.json())
|
return AdminInviteInfo.from_api_response(await resp.json())
|
||||||
|
|
||||||
@@ -1152,8 +1200,8 @@ class ProsodyClient:
|
|||||||
payload["ttl"] = ttl
|
payload["ttl"] = ttl
|
||||||
|
|
||||||
async with session.post(
|
async with session.post(
|
||||||
self._admin_v1_endpoint("/invites/reset"),
|
self._admin_v1_endpoint("/invites/reset"), json=payload
|
||||||
json=payload) as resp:
|
) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
return AdminInviteInfo.from_api_response(await resp.json())
|
return AdminInviteInfo.from_api_response(await resp.json())
|
||||||
|
|
||||||
@@ -1171,8 +1219,8 @@ class ProsodyClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async with session.post(
|
async with session.post(
|
||||||
self._admin_v1_endpoint("/groups"),
|
self._admin_v1_endpoint("/groups"), json=payload
|
||||||
json=payload) as resp:
|
) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
return AdminGroupInfo.from_api_response(await resp.json())
|
return AdminGroupInfo.from_api_response(await resp.json())
|
||||||
|
|
||||||
@@ -1184,10 +1232,12 @@ class ProsodyClient:
|
|||||||
) -> typing.Collection[AdminGroupInfo]:
|
) -> typing.Collection[AdminGroupInfo]:
|
||||||
async with session.get(self._admin_v1_endpoint("/groups")) as resp:
|
async with session.get(self._admin_v1_endpoint("/groups")) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
return list(map(
|
return list(
|
||||||
|
map(
|
||||||
AdminGroupInfo.from_api_response,
|
AdminGroupInfo.from_api_response,
|
||||||
await resp.json(),
|
await resp.json(),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@autosession
|
@autosession
|
||||||
async def get_group_by_id(
|
async def get_group_by_id(
|
||||||
@@ -1215,9 +1265,7 @@ class ProsodyClient:
|
|||||||
payload["name"] = new_name
|
payload["name"] = new_name
|
||||||
|
|
||||||
async with session.put(
|
async with session.put(
|
||||||
self._admin_v1_endpoint(
|
self._admin_v1_endpoint("/groups/{}".format(id_)),
|
||||||
"/groups/{}".format(id_)
|
|
||||||
),
|
|
||||||
json=payload,
|
json=payload,
|
||||||
) as resp:
|
) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
@@ -1231,9 +1279,7 @@ class ProsodyClient:
|
|||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with session.put(
|
async with session.put(
|
||||||
self._admin_v1_endpoint(
|
self._admin_v1_endpoint("/groups/{}/members/{}".format(id_, localpart)),
|
||||||
"/groups/{}/members/{}".format(id_, localpart)
|
|
||||||
),
|
|
||||||
) as resp:
|
) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
|
|
||||||
@@ -1251,9 +1297,7 @@ class ProsodyClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async with session.post(
|
async with session.post(
|
||||||
self._admin_v1_endpoint(
|
self._admin_v1_endpoint("/groups/{}/chats".format(id_)),
|
||||||
"/groups/{}/chats".format(id_)
|
|
||||||
),
|
|
||||||
json=payload,
|
json=payload,
|
||||||
) as resp:
|
) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
@@ -1267,9 +1311,7 @@ class ProsodyClient:
|
|||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with session.delete(
|
async with session.delete(
|
||||||
self._admin_v1_endpoint(
|
self._admin_v1_endpoint("/groups/{}/members/{}".format(id_, localpart)),
|
||||||
"/groups/{}/members/{}".format(id_, localpart)
|
|
||||||
),
|
|
||||||
) as resp:
|
) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
|
|
||||||
@@ -1282,9 +1324,7 @@ class ProsodyClient:
|
|||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with session.delete(
|
async with session.delete(
|
||||||
self._admin_v1_endpoint(
|
self._admin_v1_endpoint("/groups/{}/chats/{}".format(group_id, chat_id)),
|
||||||
"/groups/{}/chats/{}".format(group_id, chat_id)
|
|
||||||
),
|
|
||||||
) as resp:
|
) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
|
|
||||||
@@ -1307,7 +1347,9 @@ class ProsodyClient:
|
|||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
) -> typing.Optional[str]:
|
) -> typing.Optional[str]:
|
||||||
async with session.get(
|
async with session.get(
|
||||||
self._xep227_endpoint("/export?stores=roster,vcard,pep,pep_data"), # noqa:E501
|
self._xep227_endpoint(
|
||||||
|
"/export?stores=roster,vcard,pep,pep_data"
|
||||||
|
), # noqa:E501
|
||||||
) as resp:
|
) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
if resp.status == 204:
|
if resp.status == 204:
|
||||||
@@ -1322,16 +1364,15 @@ class ProsodyClient:
|
|||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
async with session.put(
|
async with session.put(
|
||||||
self._xep227_endpoint("/import?stores=roster,vcard,pep,pep_data"), # noqa:E501
|
self._xep227_endpoint(
|
||||||
|
"/import?stores=roster,vcard,pep,pep_data"
|
||||||
|
), # noqa:E501
|
||||||
data=user_xml,
|
data=user_xml,
|
||||||
) as resp:
|
) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def revoke_token(
|
async def revoke_token(self, *, session: aiohttp.ClientSession) -> None:
|
||||||
self,
|
|
||||||
*,
|
|
||||||
session: aiohttp.ClientSession) -> None:
|
|
||||||
request = aiohttp.FormData()
|
request = aiohttp.FormData()
|
||||||
request.add_field("token", self.session_token)
|
request.add_field("token", self.session_token)
|
||||||
request.add_field("token_type_hint", "access_token")
|
request.add_field("token_type_hint", "access_token")
|
||||||
@@ -1344,8 +1385,7 @@ class ProsodyClient:
|
|||||||
async with self._plain_session as session:
|
async with self._plain_session as session:
|
||||||
await self.revoke_token(session=session)
|
await self.revoke_token(session=session)
|
||||||
except aiohttp.ClientError:
|
except aiohttp.ClientError:
|
||||||
self.logger.warn("failed to revoke token!",
|
self.logger.warn("failed to revoke token!", exc_info=True)
|
||||||
exc_info=True)
|
|
||||||
http_session.pop(self.SESSION_TOKEN, None)
|
http_session.pop(self.SESSION_TOKEN, None)
|
||||||
http_session.pop(self.SESSION_ADDRESS, None)
|
http_session.pop(self.SESSION_ADDRESS, None)
|
||||||
http_session.pop(self.SESSION_CACHED_SCOPE, None)
|
http_session.pop(self.SESSION_CACHED_SCOPE, None)
|
||||||
@@ -1359,9 +1399,9 @@ class ProsodyClient:
|
|||||||
|
|
||||||
async def get_public_invite_by_id(self, id_: str) -> PublicInviteInfo:
|
async def get_public_invite_by_id(self, id_: str) -> PublicInviteInfo:
|
||||||
async with self._plain_session as session:
|
async with self._plain_session as session:
|
||||||
async with session.get(self._public_v1_endpoint(
|
async with session.get(
|
||||||
"/invite/{}".format(id_)
|
self._public_v1_endpoint("/invite/{}".format(id_))
|
||||||
)) as resp:
|
) as resp:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return PublicInviteInfo.from_api_response(await resp.json())
|
return PublicInviteInfo.from_api_response(await resp.json())
|
||||||
|
|
||||||
@@ -1378,16 +1418,15 @@ class ProsodyClient:
|
|||||||
}
|
}
|
||||||
async with self._plain_session as session:
|
async with self._plain_session as session:
|
||||||
async with session.post(
|
async with session.post(
|
||||||
self._public_v1_endpoint("/register"),
|
self._public_v1_endpoint("/register"), json=payload
|
||||||
json=payload) as resp:
|
) as resp:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return (await resp.json())["jid"]
|
return (await resp.json())["jid"]
|
||||||
|
|
||||||
@autosession
|
@autosession
|
||||||
async def get_system_metrics(
|
async def get_system_metrics(
|
||||||
self,
|
self, *, session: aiohttp.ClientSession
|
||||||
*,
|
) -> typing.Mapping:
|
||||||
session: aiohttp.ClientSession) -> typing.Mapping:
|
|
||||||
async with session.get(
|
async with session.get(
|
||||||
self._admin_v1_endpoint("/server/metrics"),
|
self._admin_v1_endpoint("/server/metrics"),
|
||||||
) as resp:
|
) as resp:
|
||||||
@@ -1399,11 +1438,8 @@ class ProsodyClient:
|
|||||||
|
|
||||||
@autosession
|
@autosession
|
||||||
async def post_announcement(
|
async def post_announcement(
|
||||||
self,
|
self, body: str, recipients: str, *, session: aiohttp.ClientSession
|
||||||
body: str,
|
) -> None:
|
||||||
recipients: str,
|
|
||||||
*,
|
|
||||||
session: aiohttp.ClientSession) -> None:
|
|
||||||
recipients_payload: typing.Union[str, typing.Sequence[str]]
|
recipients_payload: typing.Union[str, typing.Sequence[str]]
|
||||||
if recipients == "self":
|
if recipients == "self":
|
||||||
recipients_payload = [self.session_address]
|
recipients_payload = [self.session_address]
|
||||||
@@ -1416,7 +1452,7 @@ class ProsodyClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async with session.post(
|
async with session.post(
|
||||||
self._admin_v1_endpoint("/server/announcement"),
|
self._admin_v1_endpoint("/server/announcement"), json=payload
|
||||||
json=payload) as resp:
|
) as resp:
|
||||||
self._raise_error_from_response(resp)
|
self._raise_error_from_response(resp)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|||||||
BIN
snikket_web/static/img/google/da_badge_web_generic.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
snikket_web/static/img/google/de_badge_web_generic.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
snikket_web/static/img/google/en_badge_web_generic.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
snikket_web/static/img/google/es_badge_web_generic.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
snikket_web/static/img/google/fr_badge_web_generic.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
snikket_web/static/img/google/id_badge_web_generic.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
snikket_web/static/img/google/it_badge_web_generic.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
snikket_web/static/img/google/ja_badge_web_generic.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
snikket_web/static/img/google/pl_badge_web_generic.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
snikket_web/static/img/google/ru_badge_web_generic.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
snikket_web/static/img/google/sv_badge_web_generic.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
@@ -32,7 +32,7 @@
|
|||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
<div class="install-buttons">
|
<div class="install-buttons">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{{ play_store_url }}"><img alt='{% trans %}Get it on Google Play{% endtrans %}' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' class="play"/></a></li>
|
<li><a href="{{ play_store_url }}"><img alt='{% trans %}Get it on Google Play{% endtrans %}' src='{{ play_store_badge() }}' class="play"/></a></li>
|
||||||
{%- if apple_store_url -%}
|
{%- if apple_store_url -%}
|
||||||
<li><a href="{{ apple_store_url }}" class="popover" data-popover-id="apple-popover"><img alt='{% trans %}Download on the App Store{% endtrans %}' src="{{ apple_store_badge() }}" class="apple"></a></li>
|
<li><a href="{{ apple_store_url }}" class="popover" data-popover-id="apple-popover"><img alt='{% trans %}Download on the App Store{% endtrans %}' src="{{ apple_store_badge() }}" class="apple"></a></li>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
# Translations template for PROJECT.
|
# Translations template for PROJECT.
|
||||||
# Copyright (C) 2024 ORGANIZATION
|
# Copyright (C) 2025 ORGANIZATION
|
||||||
# This file is distributed under the same license as the PROJECT project.
|
# This file is distributed under the same license as the PROJECT project.
|
||||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2024.
|
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
|
||||||
#
|
#
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2024-08-11 16:36+0200\n"
|
"POT-Creation-Date: 2025-04-12 20:21+0200\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=utf-8\n"
|
"Content-Type: text/plain; charset=utf-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.16.0\n"
|
"Generated-By: Babel 2.17.0\n"
|
||||||
|
|
||||||
#: snikket_web/admin.py:69 snikket_web/templates/admin_delete_user.html:10
|
#: snikket_web/admin.py:69 snikket_web/templates/admin_delete_user.html:10
|
||||||
#: snikket_web/templates/admin_edit_circle.html:73
|
#: snikket_web/templates/admin_edit_circle.html:73
|
||||||
@@ -270,7 +270,7 @@ msgstr ""
|
|||||||
msgid "Yesterday"
|
msgid "Yesterday"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/infra.py:105
|
#: snikket_web/infra.py:104
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(time)s ago"
|
msgid "%(time)s ago"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -281,59 +281,59 @@ msgid ""
|
|||||||
"contact your Snikket operator."
|
"contact your Snikket operator."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:114
|
#: snikket_web/invite.py:124
|
||||||
msgid "Username"
|
msgid "Username"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:118 snikket_web/invite.py:190 snikket_web/main.py:43
|
#: snikket_web/invite.py:128 snikket_web/invite.py:200 snikket_web/main.py:43
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:126 snikket_web/invite.py:198
|
#: snikket_web/invite.py:136 snikket_web/invite.py:208
|
||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:130 snikket_web/invite.py:202
|
#: snikket_web/invite.py:140 snikket_web/invite.py:212
|
||||||
msgid "The passwords must match."
|
msgid "The passwords must match."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:135
|
#: snikket_web/invite.py:145
|
||||||
msgid "Create account"
|
msgid "Create account"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:162
|
#: snikket_web/invite.py:172
|
||||||
msgid "That username is already taken."
|
msgid "That username is already taken."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:166 snikket_web/invite.py:235
|
#: snikket_web/invite.py:176 snikket_web/invite.py:245
|
||||||
msgid "Registration was declined for unknown reasons."
|
msgid "Registration was declined for unknown reasons."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:170
|
#: snikket_web/invite.py:180
|
||||||
msgid "The username is not valid."
|
msgid "The username is not valid."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:207 snikket_web/templates/user_home.html:37
|
#: snikket_web/invite.py:217 snikket_web/templates/user_home.html:37
|
||||||
#: snikket_web/templates/user_passwd.html:29
|
#: snikket_web/templates/user_passwd.html:29
|
||||||
msgid "Change password"
|
msgid "Change password"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:254
|
#: snikket_web/invite.py:264
|
||||||
msgid "Account data file"
|
msgid "Account data file"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:258
|
#: snikket_web/invite.py:268
|
||||||
msgid "Import data"
|
msgid "Import data"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:279
|
#: snikket_web/invite.py:289
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid ""
|
msgid ""
|
||||||
"The account data you tried to import is in an unknown format. Please "
|
"The account data you tried to import is in an unknown format. Please "
|
||||||
"upload an XML file in XEP-0227 format (provided format: %(mimetype)s)."
|
"upload an XML file in XEP-0227 format (provided format: %(mimetype)s)."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/invite.py:299 snikket_web/templates/unauth.html:18
|
#: snikket_web/invite.py:309 snikket_web/templates/unauth.html:18
|
||||||
#: snikket_web/user.py:192
|
#: snikket_web/user.py:192
|
||||||
msgid "Error"
|
msgid "Error"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -414,7 +414,7 @@ msgstr ""
|
|||||||
msgid "Password changed"
|
msgid "Password changed"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/user.py:137
|
#: snikket_web/user.py:138
|
||||||
msgid ""
|
msgid ""
|
||||||
"The chosen avatar is too big. To be able to upload larger avatars, please"
|
"The chosen avatar is too big. To be able to upload larger avatars, please"
|
||||||
" use the app."
|
" use the app."
|
||||||
|
|||||||