Compare commits
8 Commits
stable.202
...
make-lint
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69f77020b8 | ||
|
|
5ac481a4b4 | ||
|
|
56470eec01 | ||
|
|
9b4903b230 | ||
|
|
74c3946609 | ||
|
|
feabed6565 | ||
|
|
af13a3cc47 | ||
|
|
466e3e79b7 |
2
.github/workflows/main.yaml
vendored
@@ -48,7 +48,7 @@ jobs:
|
||||
pip install flake8 flake8-print
|
||||
- name: Linting
|
||||
run: |
|
||||
python -m flake8 snikket_web
|
||||
make flake8
|
||||
|
||||
translation-check:
|
||||
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
|
||||
pot_file = $(translation_basepath)/messages.pot
|
||||
|
||||
black_formatted_py = snikket_web/prosodyclient.py
|
||||
|
||||
PYTHON3 ?= python3
|
||||
SCSSC ?= sassc --load-path snikket_web/scss/
|
||||
|
||||
@@ -34,4 +36,20 @@ compile_translations:
|
||||
-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
|
||||
|
||||
@@ -47,10 +47,20 @@ def apple_store_badge() -> str:
|
||||
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
|
||||
def context() -> typing.Dict[str, typing.Any]:
|
||||
return {
|
||||
"apple_store_badge": apple_store_badge,
|
||||
"play_store_badge": play_store_badge,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,11 +12,15 @@ import typing_extensions
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import BasicAuth
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from quart import (
|
||||
current_app, session as http_session, abort, redirect,
|
||||
current_app,
|
||||
session as http_session,
|
||||
abort,
|
||||
redirect,
|
||||
url_for,
|
||||
)
|
||||
import quart
|
||||
@@ -56,14 +60,10 @@ class UserDeletionRequestInfo:
|
||||
if data is None:
|
||||
return None
|
||||
return cls(
|
||||
deleted_at=datetime.fromtimestamp(
|
||||
data["deleted_at"],
|
||||
tz=timezone.utc
|
||||
),
|
||||
deleted_at=datetime.fromtimestamp(data["deleted_at"], tz=timezone.utc),
|
||||
pending_until=datetime.fromtimestamp(
|
||||
data["pending_until"],
|
||||
tz=timezone.utc
|
||||
)
|
||||
data["pending_until"], tz=timezone.utc
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -221,9 +221,8 @@ class AdminGroupInfo:
|
||||
name=data["name"],
|
||||
members=data.get("members", []),
|
||||
chats=[
|
||||
AdminGroupChatInfo.from_api_response(x)
|
||||
for x in data.get("chats", [])
|
||||
]
|
||||
AdminGroupChatInfo.from_api_response(x) for x in data.get("chats", [])
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -252,10 +251,12 @@ class HTTPSessionManager:
|
||||
self._app_context_attribute = app_context_attribute
|
||||
|
||||
async def _create(self) -> aiohttp.ClientSession:
|
||||
return aiohttp.ClientSession(headers={
|
||||
return aiohttp.ClientSession(
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Host": current_app.config["SNIKKET_DOMAIN"],
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
async def teardown(self, exc: typing.Optional[BaseException]) -> None:
|
||||
app_ctx = _app_ctx_stack
|
||||
@@ -298,10 +299,7 @@ class HTTPSessionManager:
|
||||
|
||||
|
||||
class HTTPAuthSessionManager(HTTPSessionManager):
|
||||
def __init__(
|
||||
self,
|
||||
app_context_attribute: str,
|
||||
session_token_key: str):
|
||||
def __init__(self, app_context_attribute: str, session_token_key: str):
|
||||
super().__init__(app_context_attribute)
|
||||
self._session_token_key = session_token_key
|
||||
|
||||
@@ -325,19 +323,20 @@ class AuthSessionProvider(typing_extensions.Protocol):
|
||||
|
||||
|
||||
def autosession(
|
||||
f: typing.Callable[..., typing.Coroutine[typing.Any, typing.Any, T]]
|
||||
) -> typing.Callable[...,
|
||||
typing.Coroutine[typing.Any, typing.Any, T]]:
|
||||
f: typing.Callable[..., typing.Coroutine[typing.Any, typing.Any, T]],
|
||||
) -> typing.Callable[..., typing.Coroutine[typing.Any, typing.Any, T]]:
|
||||
@functools.wraps(f)
|
||||
async def wrapper(
|
||||
self: AuthSessionProvider,
|
||||
*args: typing.Any,
|
||||
session: typing.Optional[aiohttp.ClientSession] = None,
|
||||
**kwargs: typing.Any) -> T:
|
||||
**kwargs: typing.Any,
|
||||
) -> T:
|
||||
if session is None:
|
||||
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
|
||||
|
||||
|
||||
@@ -352,15 +351,16 @@ class ProsodyClient:
|
||||
def __init__(self, app: typing.Optional[quart.Quart] = None):
|
||||
self._default_login_redirect: typing.Optional[str] = None
|
||||
self._plain_session = HTTPSessionManager(self.CTX_PLAIN_SESSION)
|
||||
self._auth_session = HTTPAuthSessionManager(self.CTX_AUTH_SESSION,
|
||||
self.SESSION_TOKEN)
|
||||
self.logger = logging.getLogger(
|
||||
".".join([__name__, type(self).__qualname__])
|
||||
self._auth_session = HTTPAuthSessionManager(
|
||||
self.CTX_AUTH_SESSION, self.SESSION_TOKEN
|
||||
)
|
||||
self.logger = logging.getLogger(".".join([__name__, type(self).__qualname__]))
|
||||
self.app = app
|
||||
if app is not None:
|
||||
self.init_app(app)
|
||||
|
||||
self._client_info: typing.Optional[typing.Mapping[str, str]] = None
|
||||
|
||||
@property
|
||||
def default_login_redirect(self) -> typing.Optional[str]:
|
||||
return self._default_login_redirect
|
||||
@@ -390,6 +390,10 @@ class ProsodyClient:
|
||||
def _rest_endpoint(self) -> str:
|
||||
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:
|
||||
return "{}/admin_api{}".format(self._endpoint_base, subpath)
|
||||
|
||||
@@ -399,26 +403,30 @@ class ProsodyClient:
|
||||
def _xep227_endpoint(self, subpath: str) -> str:
|
||||
return "{}/xep227{}".format(self._endpoint_base, subpath)
|
||||
|
||||
async def _oauth2_bearer_token(self,
|
||||
session: aiohttp.ClientSession,
|
||||
jid: str,
|
||||
password: str) -> TokenInfo:
|
||||
async def _oauth2_bearer_token(
|
||||
self, session: aiohttp.ClientSession, jid: str, 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.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(
|
||||
"scope",
|
||||
" ".join([SCOPE_RESTRICTED, SCOPE_DEFAULT, SCOPE_ADMIN])
|
||||
"scope", " ".join([SCOPE_RESTRICTED, SCOPE_DEFAULT, SCOPE_ADMIN])
|
||||
)
|
||||
|
||||
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_info: typing.Mapping[str, str] = (await resp.json())
|
||||
auth_info: typing.Mapping[str, str] = await resp.json()
|
||||
|
||||
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
|
||||
# wrong.
|
||||
# we have to interpret the JSON further
|
||||
@@ -430,9 +438,7 @@ class ProsodyClient:
|
||||
self.logger.debug("oauth2 success: token_type=%r", token_type)
|
||||
if token_type != "bearer":
|
||||
raise NotImplementedError(
|
||||
"unsupported token type: {!r}".format(
|
||||
auth_info["token_type"]
|
||||
)
|
||||
"unsupported token type: {!r}".format(auth_info["token_type"])
|
||||
)
|
||||
return TokenInfo(
|
||||
token=auth_info["access_token"],
|
||||
@@ -449,10 +455,56 @@ class ProsodyClient:
|
||||
http_session[self.SESSION_TOKEN] = token_info.token
|
||||
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"])
|
||||
],
|
||||
"grant_types": ["password"],
|
||||
"response_types": ["code"],
|
||||
}
|
||||
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 with self._plain_session as session:
|
||||
token_info = await self._oauth2_bearer_token(
|
||||
session, jid, password,
|
||||
session,
|
||||
jid,
|
||||
password,
|
||||
)
|
||||
|
||||
self._store_token_in_session(token_info)
|
||||
@@ -485,12 +537,15 @@ class ProsodyClient:
|
||||
redirect_to: typing.Optional[str] = None,
|
||||
) -> typing.Callable[
|
||||
[typing.Callable[..., typing.Awaitable[T]]],
|
||||
typing.Callable[..., typing.Awaitable[
|
||||
typing.Union[T, quart.Response, werkzeug.Response]]]]:
|
||||
typing.Callable[
|
||||
..., typing.Awaitable[typing.Union[T, quart.Response, werkzeug.Response]]
|
||||
],
|
||||
]:
|
||||
def decorator(
|
||||
f: typing.Callable[..., typing.Awaitable[T]],
|
||||
) -> typing.Callable[..., typing.Awaitable[
|
||||
typing.Union[T, quart.Response, werkzeug.Response]]]:
|
||||
) -> typing.Callable[
|
||||
..., typing.Awaitable[typing.Union[T, quart.Response, werkzeug.Response]]
|
||||
]:
|
||||
@functools.wraps(f)
|
||||
async def wrapped(
|
||||
*args: typing.Any,
|
||||
@@ -499,14 +554,17 @@ class ProsodyClient:
|
||||
if not self.has_session or not (await self.test_session()):
|
||||
redirect_to_value = redirect_to
|
||||
if redirect_to_value is not False:
|
||||
redirect_to_value = \
|
||||
redirect_to_value = (
|
||||
redirect_to_value or self._default_login_redirect
|
||||
)
|
||||
if not redirect_to_value:
|
||||
raise abort(401, "Not Authorized")
|
||||
return redirect(url_for(redirect_to_value))
|
||||
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
def require_admin_session(
|
||||
@@ -514,12 +572,15 @@ class ProsodyClient:
|
||||
redirect_to: typing.Optional[str] = None,
|
||||
) -> typing.Callable[
|
||||
[typing.Callable[..., typing.Awaitable[T]]],
|
||||
typing.Callable[..., typing.Awaitable[
|
||||
typing.Union[T, quart.Response, werkzeug.Response]]]]:
|
||||
typing.Callable[
|
||||
..., typing.Awaitable[typing.Union[T, quart.Response, werkzeug.Response]]
|
||||
],
|
||||
]:
|
||||
def decorator(
|
||||
f: typing.Callable[..., typing.Awaitable[T]],
|
||||
) -> typing.Callable[..., typing.Awaitable[
|
||||
typing.Union[T, quart.Response, werkzeug.Response]]]:
|
||||
) -> typing.Callable[
|
||||
..., typing.Awaitable[typing.Union[T, quart.Response, werkzeug.Response]]
|
||||
]:
|
||||
@functools.wraps(f)
|
||||
@self.require_session(redirect_to=redirect_to)
|
||||
async def wrapped(
|
||||
@@ -530,7 +591,9 @@ class ProsodyClient:
|
||||
raise abort(403, "This is not for you.")
|
||||
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
async def _xml_iq_call(
|
||||
@@ -544,10 +607,12 @@ class ProsodyClient:
|
||||
final_headers: typing.MutableMapping[str, str] = {}
|
||||
if headers is not None:
|
||||
final_headers.update(headers)
|
||||
final_headers.update({
|
||||
final_headers.update(
|
||||
{
|
||||
"Content-Type": "application/xmpp+xml",
|
||||
"Accept": "application/xmpp+xml",
|
||||
})
|
||||
}
|
||||
)
|
||||
if not payload.get("id"):
|
||||
payload.set("id", secrets.token_hex(8))
|
||||
|
||||
@@ -555,15 +620,15 @@ class ProsodyClient:
|
||||
id_ = payload.get("id")
|
||||
self.logger.debug(
|
||||
"sending IQ (id=%s): %r",
|
||||
id_, "(sensitive)" if sensitive else serialised,
|
||||
id_,
|
||||
"(sensitive)" if sensitive else serialised,
|
||||
)
|
||||
async with session.post(self._rest_endpoint,
|
||||
headers=final_headers,
|
||||
data=serialised) as resp:
|
||||
async with session.post(
|
||||
self._rest_endpoint, headers=final_headers, data=serialised
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
self.logger.debug(
|
||||
"IQ HTTP response (in-reply-to id=%s) with non-OK status "
|
||||
"%s: %s",
|
||||
"IQ HTTP response (in-reply-to id=%s) with non-OK status " "%s: %s",
|
||||
id_,
|
||||
resp.status,
|
||||
resp.reason,
|
||||
@@ -572,7 +637,8 @@ class ProsodyClient:
|
||||
reply_payload = await resp.read()
|
||||
self.logger.debug(
|
||||
"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)
|
||||
|
||||
@@ -633,9 +699,9 @@ class ProsodyClient:
|
||||
return (await resp.json())["version"]["version"]
|
||||
except Exception as exc:
|
||||
self.logger.debug(
|
||||
"failed to parse prosody version from response"
|
||||
" (%s: %s)",
|
||||
type(exc), exc,
|
||||
"failed to parse prosody version from response" " (%s: %s)",
|
||||
type(exc),
|
||||
exc,
|
||||
)
|
||||
return "unknown"
|
||||
|
||||
@@ -646,8 +712,7 @@ class ProsodyClient:
|
||||
session: aiohttp.ClientSession,
|
||||
) -> typing.Optional[str]:
|
||||
iq_resp = await self._xml_iq_call(
|
||||
session,
|
||||
xmpputil.make_nickname_get_request(self.session_address)
|
||||
session, xmpputil.make_nickname_get_request(self.session_address)
|
||||
)
|
||||
return xmpputil.extract_nickname_get_reply(iq_resp)
|
||||
|
||||
@@ -660,8 +725,7 @@ class ProsodyClient:
|
||||
) -> None:
|
||||
iq_resp = await self._xml_iq_call(
|
||||
session,
|
||||
xmpputil.make_nickname_set_request(self.session_address,
|
||||
new_nickname)
|
||||
xmpputil.make_nickname_set_request(self.session_address, new_nickname),
|
||||
)
|
||||
# just to throw errors
|
||||
xmpputil.extract_iq_reply(iq_resp)
|
||||
@@ -675,8 +739,7 @@ class ProsodyClient:
|
||||
session: aiohttp.ClientSession,
|
||||
) -> typing.Mapping:
|
||||
metadata_resp = await self._xml_iq_call(
|
||||
session,
|
||||
xmpputil.make_avatar_metadata_request(from_)
|
||||
session, xmpputil.make_avatar_metadata_request(from_)
|
||||
)
|
||||
info = xmpputil.extract_avatar_metadata_get_reply(metadata_resp)
|
||||
if info is None:
|
||||
@@ -684,7 +747,8 @@ class ProsodyClient:
|
||||
|
||||
if not metadata_only:
|
||||
info["data"] = await self.get_avatar_data(
|
||||
from_, info["sha1"],
|
||||
from_,
|
||||
info["sha1"],
|
||||
session=session,
|
||||
)
|
||||
|
||||
@@ -699,19 +763,14 @@ class ProsodyClient:
|
||||
session: aiohttp.ClientSession,
|
||||
) -> typing.Optional[bytes]:
|
||||
data_resp = await self._xml_iq_call(
|
||||
session,
|
||||
xmpputil.make_avatar_data_request(from_, id_)
|
||||
session, xmpputil.make_avatar_data_request(from_, id_)
|
||||
)
|
||||
return xmpputil.extract_avatar_data_get_reply(data_resp)
|
||||
|
||||
@autosession
|
||||
async def get_pubsub_node_access_model(
|
||||
self,
|
||||
to: str,
|
||||
node: str,
|
||||
default: str,
|
||||
*,
|
||||
session: aiohttp.ClientSession) -> str:
|
||||
self, to: str, node: str, default: str, *, session: aiohttp.ClientSession
|
||||
) -> str:
|
||||
config = xmpputil.extract_pubsub_node_config_get_reply(
|
||||
await self._xml_iq_call(
|
||||
session,
|
||||
@@ -734,26 +793,26 @@ class ProsodyClient:
|
||||
new_access_model: str,
|
||||
*,
|
||||
ignore_not_found: bool = False,
|
||||
session: aiohttp.ClientSession) -> None:
|
||||
session: aiohttp.ClientSession,
|
||||
) -> None:
|
||||
try:
|
||||
xmpputil.extract_iq_reply(await self._xml_iq_call(
|
||||
xmpputil.extract_iq_reply(
|
||||
await self._xml_iq_call(
|
||||
session,
|
||||
xmpputil.make_pubsub_access_model_put_request(
|
||||
to,
|
||||
node,
|
||||
new_access_model,
|
||||
),
|
||||
)
|
||||
)
|
||||
))
|
||||
except werkzeug.exceptions.NotFound:
|
||||
if ignore_not_found:
|
||||
return
|
||||
raise
|
||||
|
||||
@autosession
|
||||
async def get_nickname_access_model(
|
||||
self,
|
||||
*,
|
||||
session: aiohttp.ClientSession) -> str:
|
||||
async def get_nickname_access_model(self, *, session: aiohttp.ClientSession) -> str:
|
||||
return await self.get_pubsub_node_access_model(
|
||||
self.session_address,
|
||||
xmpputil.NODE_USER_NICKNAME,
|
||||
@@ -763,10 +822,8 @@ class ProsodyClient:
|
||||
|
||||
@autosession
|
||||
async def set_nickname_access_model(
|
||||
self,
|
||||
new_access_model: str,
|
||||
*,
|
||||
session: aiohttp.ClientSession) -> None:
|
||||
self, new_access_model: str, *, session: aiohttp.ClientSession
|
||||
) -> None:
|
||||
await self.set_pubsub_node_access_model(
|
||||
self.session_address,
|
||||
xmpputil.NODE_USER_NICKNAME,
|
||||
@@ -776,10 +833,7 @@ class ProsodyClient:
|
||||
)
|
||||
|
||||
@autosession
|
||||
async def get_avatar_access_model(
|
||||
self,
|
||||
*,
|
||||
session: aiohttp.ClientSession) -> str:
|
||||
async def get_avatar_access_model(self, *, session: aiohttp.ClientSession) -> str:
|
||||
return await self.get_pubsub_node_access_model(
|
||||
self.session_address,
|
||||
xmpputil.NODE_USER_AVATAR_METADATA,
|
||||
@@ -789,10 +843,8 @@ class ProsodyClient:
|
||||
|
||||
@autosession
|
||||
async def set_avatar_access_model(
|
||||
self,
|
||||
new_access_model: str,
|
||||
*,
|
||||
session: aiohttp.ClientSession) -> None:
|
||||
self, new_access_model: str, *, session: aiohttp.ClientSession
|
||||
) -> None:
|
||||
await asyncio.gather(
|
||||
self.set_pubsub_node_access_model(
|
||||
self.session_address,
|
||||
@@ -807,14 +859,11 @@ class ProsodyClient:
|
||||
new_access_model,
|
||||
ignore_not_found=True,
|
||||
session=session,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@autosession
|
||||
async def get_vcard_access_model(
|
||||
self,
|
||||
*,
|
||||
session: aiohttp.ClientSession) -> str:
|
||||
async def get_vcard_access_model(self, *, session: aiohttp.ClientSession) -> str:
|
||||
return await self.get_pubsub_node_access_model(
|
||||
self.session_address,
|
||||
xmpputil.NODE_VCARD,
|
||||
@@ -824,10 +873,8 @@ class ProsodyClient:
|
||||
|
||||
@autosession
|
||||
async def set_vcard_access_model(
|
||||
self,
|
||||
new_access_model: str,
|
||||
*,
|
||||
session: aiohttp.ClientSession) -> None:
|
||||
self, new_access_model: str, *, session: aiohttp.ClientSession
|
||||
) -> None:
|
||||
await self.set_pubsub_node_access_model(
|
||||
self.session_address,
|
||||
xmpputil.NODE_VCARD,
|
||||
@@ -848,9 +895,7 @@ class ProsodyClient:
|
||||
|
||||
data_resp = await self._xml_iq_call(
|
||||
session,
|
||||
xmpputil.make_avatar_data_set_request(self.session_address,
|
||||
data,
|
||||
id_)
|
||||
xmpputil.make_avatar_data_set_request(self.session_address, data, id_),
|
||||
)
|
||||
xmpputil.extract_iq_reply(data_resp)
|
||||
|
||||
@@ -863,7 +908,7 @@ class ProsodyClient:
|
||||
size=len(data),
|
||||
width=None,
|
||||
height=None,
|
||||
)
|
||||
),
|
||||
)
|
||||
xmpputil.extract_iq_reply(metadata_resp)
|
||||
|
||||
@@ -880,7 +925,7 @@ class ProsodyClient:
|
||||
self.get_nickname_access_model(session=session),
|
||||
self.get_vcard_access_model(session=session),
|
||||
return_exceptions=True,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
order = [
|
||||
@@ -921,8 +966,7 @@ class ProsodyClient:
|
||||
password_changed = await self._xml_iq_call(
|
||||
session,
|
||||
xmpputil.make_password_change_request(
|
||||
self.session_address,
|
||||
new_password
|
||||
self.session_address, new_password
|
||||
),
|
||||
headers={
|
||||
"Authorization": "Bearer {}".format(token_info.token),
|
||||
@@ -1057,8 +1101,7 @@ class ProsodyClient:
|
||||
) -> typing.Collection[AdminInviteInfo]:
|
||||
async with session.get(self._admin_v1_endpoint("/invites")) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
return list(map(AdminInviteInfo.from_api_response,
|
||||
await resp.json()))
|
||||
return list(map(AdminInviteInfo.from_api_response, await resp.json()))
|
||||
|
||||
@autosession
|
||||
async def get_invite_by_id(
|
||||
@@ -1107,8 +1150,8 @@ class ProsodyClient:
|
||||
payload["note"] = note
|
||||
|
||||
async with session.post(
|
||||
self._admin_v1_endpoint("/invites/account"),
|
||||
json=payload) as resp:
|
||||
self._admin_v1_endpoint("/invites/account"), json=payload
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
return AdminInviteInfo.from_api_response(await resp.json())
|
||||
|
||||
@@ -1132,8 +1175,8 @@ class ProsodyClient:
|
||||
payload["note"] = note
|
||||
|
||||
async with session.post(
|
||||
self._admin_v1_endpoint("/invites/group"),
|
||||
json=payload) as resp:
|
||||
self._admin_v1_endpoint("/invites/group"), json=payload
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
return AdminInviteInfo.from_api_response(await resp.json())
|
||||
|
||||
@@ -1152,8 +1195,8 @@ class ProsodyClient:
|
||||
payload["ttl"] = ttl
|
||||
|
||||
async with session.post(
|
||||
self._admin_v1_endpoint("/invites/reset"),
|
||||
json=payload) as resp:
|
||||
self._admin_v1_endpoint("/invites/reset"), json=payload
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
return AdminInviteInfo.from_api_response(await resp.json())
|
||||
|
||||
@@ -1171,8 +1214,8 @@ class ProsodyClient:
|
||||
}
|
||||
|
||||
async with session.post(
|
||||
self._admin_v1_endpoint("/groups"),
|
||||
json=payload) as resp:
|
||||
self._admin_v1_endpoint("/groups"), json=payload
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
return AdminGroupInfo.from_api_response(await resp.json())
|
||||
|
||||
@@ -1184,10 +1227,12 @@ class ProsodyClient:
|
||||
) -> typing.Collection[AdminGroupInfo]:
|
||||
async with session.get(self._admin_v1_endpoint("/groups")) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
return list(map(
|
||||
return list(
|
||||
map(
|
||||
AdminGroupInfo.from_api_response,
|
||||
await resp.json(),
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
@autosession
|
||||
async def get_group_by_id(
|
||||
@@ -1215,9 +1260,7 @@ class ProsodyClient:
|
||||
payload["name"] = new_name
|
||||
|
||||
async with session.put(
|
||||
self._admin_v1_endpoint(
|
||||
"/groups/{}".format(id_)
|
||||
),
|
||||
self._admin_v1_endpoint("/groups/{}".format(id_)),
|
||||
json=payload,
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
@@ -1231,9 +1274,7 @@ class ProsodyClient:
|
||||
session: aiohttp.ClientSession,
|
||||
) -> None:
|
||||
async with session.put(
|
||||
self._admin_v1_endpoint(
|
||||
"/groups/{}/members/{}".format(id_, localpart)
|
||||
),
|
||||
self._admin_v1_endpoint("/groups/{}/members/{}".format(id_, localpart)),
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
|
||||
@@ -1251,9 +1292,7 @@ class ProsodyClient:
|
||||
}
|
||||
|
||||
async with session.post(
|
||||
self._admin_v1_endpoint(
|
||||
"/groups/{}/chats".format(id_)
|
||||
),
|
||||
self._admin_v1_endpoint("/groups/{}/chats".format(id_)),
|
||||
json=payload,
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
@@ -1267,9 +1306,7 @@ class ProsodyClient:
|
||||
session: aiohttp.ClientSession,
|
||||
) -> None:
|
||||
async with session.delete(
|
||||
self._admin_v1_endpoint(
|
||||
"/groups/{}/members/{}".format(id_, localpart)
|
||||
),
|
||||
self._admin_v1_endpoint("/groups/{}/members/{}".format(id_, localpart)),
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
|
||||
@@ -1282,9 +1319,7 @@ class ProsodyClient:
|
||||
session: aiohttp.ClientSession,
|
||||
) -> None:
|
||||
async with session.delete(
|
||||
self._admin_v1_endpoint(
|
||||
"/groups/{}/chats/{}".format(group_id, chat_id)
|
||||
),
|
||||
self._admin_v1_endpoint("/groups/{}/chats/{}".format(group_id, chat_id)),
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
|
||||
@@ -1307,7 +1342,9 @@ class ProsodyClient:
|
||||
session: aiohttp.ClientSession,
|
||||
) -> typing.Optional[str]:
|
||||
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:
|
||||
self._raise_error_from_response(resp)
|
||||
if resp.status == 204:
|
||||
@@ -1322,16 +1359,15 @@ class ProsodyClient:
|
||||
session: aiohttp.ClientSession,
|
||||
) -> bool:
|
||||
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,
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
return True
|
||||
|
||||
async def revoke_token(
|
||||
self,
|
||||
*,
|
||||
session: aiohttp.ClientSession) -> None:
|
||||
async def revoke_token(self, *, session: aiohttp.ClientSession) -> None:
|
||||
request = aiohttp.FormData()
|
||||
request.add_field("token", self.session_token)
|
||||
request.add_field("token_type_hint", "access_token")
|
||||
@@ -1344,8 +1380,7 @@ class ProsodyClient:
|
||||
async with self._plain_session as session:
|
||||
await self.revoke_token(session=session)
|
||||
except aiohttp.ClientError:
|
||||
self.logger.warn("failed to revoke token!",
|
||||
exc_info=True)
|
||||
self.logger.warn("failed to revoke token!", exc_info=True)
|
||||
http_session.pop(self.SESSION_TOKEN, None)
|
||||
http_session.pop(self.SESSION_ADDRESS, None)
|
||||
http_session.pop(self.SESSION_CACHED_SCOPE, None)
|
||||
@@ -1359,9 +1394,9 @@ class ProsodyClient:
|
||||
|
||||
async def get_public_invite_by_id(self, id_: str) -> PublicInviteInfo:
|
||||
async with self._plain_session as session:
|
||||
async with session.get(self._public_v1_endpoint(
|
||||
"/invite/{}".format(id_)
|
||||
)) as resp:
|
||||
async with session.get(
|
||||
self._public_v1_endpoint("/invite/{}".format(id_))
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return PublicInviteInfo.from_api_response(await resp.json())
|
||||
|
||||
@@ -1378,16 +1413,15 @@ class ProsodyClient:
|
||||
}
|
||||
async with self._plain_session as session:
|
||||
async with session.post(
|
||||
self._public_v1_endpoint("/register"),
|
||||
json=payload) as resp:
|
||||
self._public_v1_endpoint("/register"), json=payload
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return (await resp.json())["jid"]
|
||||
|
||||
@autosession
|
||||
async def get_system_metrics(
|
||||
self,
|
||||
*,
|
||||
session: aiohttp.ClientSession) -> typing.Mapping:
|
||||
self, *, session: aiohttp.ClientSession
|
||||
) -> typing.Mapping:
|
||||
async with session.get(
|
||||
self._admin_v1_endpoint("/server/metrics"),
|
||||
) as resp:
|
||||
@@ -1399,11 +1433,8 @@ class ProsodyClient:
|
||||
|
||||
@autosession
|
||||
async def post_announcement(
|
||||
self,
|
||||
body: str,
|
||||
recipients: str,
|
||||
*,
|
||||
session: aiohttp.ClientSession) -> None:
|
||||
self, body: str, recipients: str, *, session: aiohttp.ClientSession
|
||||
) -> None:
|
||||
recipients_payload: typing.Union[str, typing.Sequence[str]]
|
||||
if recipients == "self":
|
||||
recipients_payload = [self.session_address]
|
||||
@@ -1416,7 +1447,7 @@ class ProsodyClient:
|
||||
}
|
||||
|
||||
async with session.post(
|
||||
self._admin_v1_endpoint("/server/announcement"),
|
||||
json=payload) as resp:
|
||||
self._admin_v1_endpoint("/server/announcement"), json=payload
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
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 -%}
|
||||
<div class="install-buttons">
|
||||
<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 -%}
|
||||
<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 -%}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# Translations template for PROJECT.
|
||||
# Copyright (C) 2024 ORGANIZATION
|
||||
# Copyright (C) 2025 ORGANIZATION
|
||||
# This file is distributed under the same license as the PROJECT project.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2024.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\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"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\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/templates/admin_edit_circle.html:73
|
||||
@@ -270,7 +270,7 @@ msgstr ""
|
||||
msgid "Yesterday"
|
||||
msgstr ""
|
||||
|
||||
#: snikket_web/infra.py:105
|
||||
#: snikket_web/infra.py:104
|
||||
#, python-format
|
||||
msgid "%(time)s ago"
|
||||
msgstr ""
|
||||
@@ -281,59 +281,59 @@ msgid ""
|
||||
"contact your Snikket operator."
|
||||
msgstr ""
|
||||
|
||||
#: snikket_web/invite.py:114
|
||||
#: snikket_web/invite.py:124
|
||||
msgid "Username"
|
||||
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"
|
||||
msgstr ""
|
||||
|
||||
#: snikket_web/invite.py:126 snikket_web/invite.py:198
|
||||
#: snikket_web/invite.py:136 snikket_web/invite.py:208
|
||||
msgid "Confirm password"
|
||||
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."
|
||||
msgstr ""
|
||||
|
||||
#: snikket_web/invite.py:135
|
||||
#: snikket_web/invite.py:145
|
||||
msgid "Create account"
|
||||
msgstr ""
|
||||
|
||||
#: snikket_web/invite.py:162
|
||||
#: snikket_web/invite.py:172
|
||||
msgid "That username is already taken."
|
||||
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."
|
||||
msgstr ""
|
||||
|
||||
#: snikket_web/invite.py:170
|
||||
#: snikket_web/invite.py:180
|
||||
msgid "The username is not valid."
|
||||
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
|
||||
msgid "Change password"
|
||||
msgstr ""
|
||||
|
||||
#: snikket_web/invite.py:254
|
||||
#: snikket_web/invite.py:264
|
||||
msgid "Account data file"
|
||||
msgstr ""
|
||||
|
||||
#: snikket_web/invite.py:258
|
||||
#: snikket_web/invite.py:268
|
||||
msgid "Import data"
|
||||
msgstr ""
|
||||
|
||||
#: snikket_web/invite.py:279
|
||||
#: snikket_web/invite.py:289
|
||||
#, python-format
|
||||
msgid ""
|
||||
"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)."
|
||||
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
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
@@ -414,7 +414,7 @@ msgstr ""
|
||||
msgid "Password changed"
|
||||
msgstr ""
|
||||
|
||||
#: snikket_web/user.py:137
|
||||
#: snikket_web/user.py:138
|
||||
msgid ""
|
||||
"The chosen avatar is too big. To be able to upload larger avatars, please"
|
||||
" use the app."
|
||||
|
||||