Compare commits

...

16 Commits

Author SHA1 Message Date
Matthew Wild
488dc9a3f3 Merge pull request #202 from snikket-im/ka/oauthtweaks
OAuth tweaks
2025-06-05 17:55:56 +01:00
Kim Alvefur
1a65ba6150 Include a software id in oauth client registration
This is supposed to be a unique and persistent identifier for the
software itself, regardless of version or deployment instance.

Generated from the domain name in the comment using uuid_generate_sha1()
2025-06-05 17:52:04 +01:00
Kim Alvefur
9474238dee Declare as a web application in oauth client registration
It is, even if the password grant isn't restricted to that, but if ever
the authorization code flow is implemented, it'll be correct.
2025-06-05 17:52:01 +01:00
Kim Alvefur
60e663316b Declare that oauth client credentials are using POST method
Not enforced by mod_http_oauth2, but could be in the future
2025-06-05 17:50:52 +01:00
Kim Alvefur
770d05c72c Declare use of no response types, since password grant uses none
Needless restriction removed in
https://hg.prosody.im/prosody-modules/rev/ef81c67e1ae7
2025-06-05 17:50:52 +01:00
Kim Alvefur
ea75d8e832 Include requested scopes in oauth client registration
This can be used on the oauth server side to enforce that no additional
scopes are added.
2025-06-05 17:50:52 +01:00
Kim Alvefur
145dda8c19 Include web portal version in oauth client registration
This could be shown in client listings and audit logs, and checked to
ensure old versions stop being used. Not the most relevant for the web
portal as it is closely tied together with the server, but could help
answer questions about where old grants come from.
2025-06-05 17:50:52 +01:00
Matthew Wild
149a79cb2c Merge pull request #203 from snikket-im/make-lint
prosodyclient: Fixes to satisfy mypy
2025-06-05 17:48:23 +01:00
Matthew Wild
69f77020b8 prosodyclient: Fixes to satisfy mypy 2025-06-05 17:46:05 +01:00
Matthew Wild
5ac481a4b4 prosodyclient: Switch to black formatting and remove lint issues 2025-06-05 17:33:22 +01:00
Matthew Wild
56470eec01 Github: Use flake8 target 2025-06-05 17:32:34 +01:00
Matthew Wild
9b4903b230 Makefile: Add lint, format (black), flake8 and mypy targets 2025-06-05 17:29:44 +01:00
Matthew Wild
74c3946609 Bump log level of oauth errors 2025-06-02 11:36:08 +01:00
Matthew Wild
feabed6565 Register as an OAuth client and authenticate token requests
mod_http_oauth2 in prosody-modules was updated to require client
authentication for the password grant, which previously did not need
client authentication.

This means that the first request we make to Prosody will now register as a
client in order to obtain client_id and client_secret.

There is no real security gain from this approach (unlike other grant types,
the password grant does not do redirects which could be intercepted). In the
future, however, some security could be gained by having Prosody restrict
the ability to use the password grant to privileged OAuth clients. This would
prevent third-party OAuth clients from using the password grant which is not
suitable for that purpose.
2025-06-02 11:36:08 +01:00
Jonas Schäfer
af13a3cc47 Merge pull request #197 from snikket-im/z/play-badge-l10n
Serve localized Google Play badges locally
2025-04-13 08:57:16 +02:00
Kim Alvefur
466e3e79b7 Serve localized Google Play badges locally
Fixes #196

Badges downloaded from <https://play.google.com/intl/en_us/badges/> with
a bit of automation to get one per supported language.
2025-04-12 20:23:10 +02:00
17 changed files with 532 additions and 468 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,
} }

View File

@@ -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 thats what can happen when some stuff is # OAuth2 spec says thats 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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -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 -%}

View File

@@ -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."