You've already forked snikket-web-portal
Add support for a profile change page
This commit is contained in:
@@ -10,28 +10,8 @@ from quart import (
|
|||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from . import xmpputil
|
||||||
def split_jid(s):
|
from .xmpputil import split_jid
|
||||||
bare, sep, resource = s.partition("/")
|
|
||||||
if not sep:
|
|
||||||
resource = None
|
|
||||||
localpart, sep, domain = bare.partition("@")
|
|
||||||
if not sep:
|
|
||||||
domain = localpart
|
|
||||||
localpart = None
|
|
||||||
return localpart, domain, resource
|
|
||||||
|
|
||||||
|
|
||||||
def _mk_change_password_request(jid, password):
|
|
||||||
username, domain, _ = split_jid(jid)
|
|
||||||
# XXX: this is due to a problem with mod_rest / mod_register in prosody:
|
|
||||||
# it doesn’t recognize the password change stanza unless we send it to
|
|
||||||
# the account JID.
|
|
||||||
req = ET.Element("iq", to="{}@{}".format(username, domain), type="set")
|
|
||||||
q = ET.SubElement(req, "query", xmlns="jabber:iq:register")
|
|
||||||
ET.SubElement(q, "username").text = username
|
|
||||||
ET.SubElement(q, "password").text = password
|
|
||||||
return ET.tostring(req)
|
|
||||||
|
|
||||||
|
|
||||||
class HTTPSessionManager:
|
class HTTPSessionManager:
|
||||||
@@ -50,7 +30,7 @@ class HTTPSessionManager:
|
|||||||
|
|
||||||
if exc is not None:
|
if exc is not None:
|
||||||
exc_type = type(exc)
|
exc_type = type(exc)
|
||||||
traceback = getattr(exc.__traceback__, None)
|
traceback = getattr(exc, "__traceback__", None)
|
||||||
else:
|
else:
|
||||||
exc_type = None
|
exc_type = None
|
||||||
traceback = None
|
traceback = None
|
||||||
@@ -93,6 +73,19 @@ class HTTPAuthSessionManager(HTTPSessionManager):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def autosession(f):
|
||||||
|
@functools.wraps(f)
|
||||||
|
async def wrapper(self, *args, session=None, **kwargs):
|
||||||
|
print(f)
|
||||||
|
print(f.__code__.co_argcount, f.__code__.co_varnames)
|
||||||
|
print(args, kwargs)
|
||||||
|
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 wrapper
|
||||||
|
|
||||||
|
|
||||||
class ProsodyClient:
|
class ProsodyClient:
|
||||||
CTX_PLAIN_SESSION = "_ProsodyClient__session"
|
CTX_PLAIN_SESSION = "_ProsodyClient__session"
|
||||||
CTX_AUTH_SESSION = "_ProsodyClient__auth_session"
|
CTX_AUTH_SESSION = "_ProsodyClient__auth_session"
|
||||||
@@ -211,27 +204,39 @@ class ProsodyClient:
|
|||||||
async with session.post(self._rest_endpoint,
|
async with session.post(self._rest_endpoint,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
data=payload) as resp:
|
data=payload) as resp:
|
||||||
|
print(payload)
|
||||||
reply_payload = await resp.read()
|
reply_payload = await resp.read()
|
||||||
|
print(reply_payload)
|
||||||
return ET.fromstring(reply_payload)
|
return ET.fromstring(reply_payload)
|
||||||
|
|
||||||
async def get_user_info(self):
|
async def get_user_info(self):
|
||||||
localpart, domain, _ = split_jid(self.session_address)
|
localpart, domain, _ = split_jid(self.session_address)
|
||||||
|
|
||||||
request = {
|
|
||||||
"kind": "iq",
|
|
||||||
"to": self.session_address,
|
|
||||||
"type": "get",
|
|
||||||
"ping": True
|
|
||||||
}
|
|
||||||
|
|
||||||
async with self._auth_session as session:
|
async with self._auth_session as session:
|
||||||
async with session.post(self._rest_endpoint,
|
nickname = await self.get_user_nickname(session=session)
|
||||||
json=request) as resp:
|
return {
|
||||||
if resp.status != 200:
|
"username": localpart,
|
||||||
raise abort(resp.status)
|
"nickname": nickname,
|
||||||
return {
|
"display_name": nickname or localpart,
|
||||||
"username": localpart,
|
}
|
||||||
}
|
|
||||||
|
@autosession
|
||||||
|
async def get_user_nickname(self, session):
|
||||||
|
iq_resp = await self._xml_iq_call(
|
||||||
|
session,
|
||||||
|
xmpputil.make_nickname_get_request(self.session_address)
|
||||||
|
)
|
||||||
|
return xmpputil.extract_nickname_get_reply(iq_resp)
|
||||||
|
|
||||||
|
@autosession
|
||||||
|
async def set_user_nickname(self, new_nickname, session):
|
||||||
|
iq_resp = await self._xml_iq_call(
|
||||||
|
session,
|
||||||
|
xmpputil.make_nickname_set_request(self.session_address,
|
||||||
|
new_nickname)
|
||||||
|
)
|
||||||
|
# just to throw errors
|
||||||
|
xmpputil.extract_iq_reply(iq_resp)
|
||||||
|
|
||||||
async def change_password(self, current_password, new_password):
|
async def change_password(self, current_password, new_password):
|
||||||
# we play it safe here and do not use the existing auth session;
|
# we play it safe here and do not use the existing auth session;
|
||||||
@@ -246,7 +251,10 @@ class ProsodyClient:
|
|||||||
)
|
)
|
||||||
reply = await self._xml_iq_call(
|
reply = await self._xml_iq_call(
|
||||||
session,
|
session,
|
||||||
_mk_change_password_request(self.session_address, new_password),
|
xmpputil.make_password_change_request(
|
||||||
|
self.session_address,
|
||||||
|
new_password
|
||||||
|
),
|
||||||
headers={
|
headers={
|
||||||
"Authorization": "Bearer {}".format(token),
|
"Authorization": "Bearer {}".format(token),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div id="topbar">
|
<div id="topbar">
|
||||||
<header><a href="{{ url_for('user.index') }}"><span>{{ config["SNIKKET_DOMAIN"] }}</span></a></header>
|
<header><a href="{{ url_for('user.index') }}"><span>{{ config["SNIKKET_DOMAIN"] }}</span></a></header>
|
||||||
<div class="filler"></div>
|
<div class="filler"></div>
|
||||||
<nav class="usermenu">{{ user_info.username }}</nav>
|
<nav class="usermenu">{{ user_info.display_name }}</nav>
|
||||||
</div>
|
</div>
|
||||||
<main>{% block content %}{% endblock %}</main>
|
<main>{% block content %}{% endblock %}</main>
|
||||||
<footer>
|
<footer>
|
||||||
|
|||||||
17
snikket_web/templates/user_profile.html
Normal file
17
snikket_web/templates/user_profile.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "app.html" %}
|
||||||
|
{% block head_lead %}
|
||||||
|
<title>Snikket Web Portal</title>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="form layout-expanded"><form method="POST">
|
||||||
|
<h2 class="form-title">Profile</h2>
|
||||||
|
{{ form.csrf_token }}
|
||||||
|
<div class="f-ebox">
|
||||||
|
{{ form.nickname.label }}
|
||||||
|
{{ form.nickname(placeholder=user_info.username) }}
|
||||||
|
</div>
|
||||||
|
<div class="f-bbox">
|
||||||
|
<a href="{{ url_for('user.index') }}" class="button secondary">Back</a><button type="submit" class="primary">Update</button>
|
||||||
|
</div>
|
||||||
|
</form></div>
|
||||||
|
{% endblock %}
|
||||||
@@ -44,6 +44,12 @@ class LogoutForm(FlaskForm):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileForm(FlaskForm):
|
||||||
|
nickname = wtforms.TextField(
|
||||||
|
"Display name",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@user_bp.route("/")
|
@user_bp.route("/")
|
||||||
async def index():
|
async def index():
|
||||||
user_info = await client.get_user_info()
|
user_info = await client.get_user_info()
|
||||||
@@ -71,6 +77,22 @@ async def change_pw():
|
|||||||
return await render_template("user_passwd.html", form=form)
|
return await render_template("user_passwd.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@user_bp.route("/profile", methods=["GET", "POST"])
|
||||||
|
async def profile():
|
||||||
|
form = ProfileForm()
|
||||||
|
if request.method != "POST":
|
||||||
|
user_info = await client.get_user_info()
|
||||||
|
form.nickname.data = user_info.get("nickname", "")
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user_info = await client.get_user_info()
|
||||||
|
if user_info.get("nickname") != form.nickname.data:
|
||||||
|
await client.set_user_nickname(form.nickname.data)
|
||||||
|
return redirect(url_for(".profile"))
|
||||||
|
|
||||||
|
return await render_template("user_profile.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
@user_bp.route("/logout", methods=["GET", "POST"])
|
@user_bp.route("/logout", methods=["GET", "POST"])
|
||||||
async def logout():
|
async def logout():
|
||||||
form = LogoutForm()
|
form = LogoutForm()
|
||||||
|
|||||||
130
snikket_web/xmpputil.py
Normal file
130
snikket_web/xmpputil.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import typing
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from quart import abort
|
||||||
|
import quart.exceptions
|
||||||
|
|
||||||
|
|
||||||
|
TAG_XMPP_ERROR = "error"
|
||||||
|
|
||||||
|
NS_XMPP_ERROR_CONDITION = "urn:ietf:params:xml:ns:xmpp-stanzas"
|
||||||
|
TAG_XMPP_ERROR_ITEM_NOT_FOUND = "{{{}}}item-not-found".format(NS_XMPP_ERROR_CONDITION)
|
||||||
|
TAG_XMPP_ERROR_TEXT = "{{{}}}text".format(TAG_XMPP_ERROR_ITEM_NOT_FOUND)
|
||||||
|
|
||||||
|
ERROR_CODE_MAP = {
|
||||||
|
TAG_XMPP_ERROR_ITEM_NOT_FOUND: 404,
|
||||||
|
}
|
||||||
|
|
||||||
|
NS_PUBSUB = "http://jabber.org/protocol/pubsub"
|
||||||
|
TAG_PUBSUB = "{{{}}}pubsub".format(NS_PUBSUB)
|
||||||
|
TAG_PUBSUB_ITEM = "{{{}}}item".format(NS_PUBSUB)
|
||||||
|
TAG_PUBSUB_ITEMS = "{{{}}}items".format(NS_PUBSUB)
|
||||||
|
|
||||||
|
NS_USER_NICKNAME = "http://jabber.org/protocol/nick"
|
||||||
|
TAG_USER_NICKNAME_NICK = "{{{}}}nick".format(NS_USER_NICKNAME)
|
||||||
|
|
||||||
|
|
||||||
|
def split_jid(s):
|
||||||
|
bare, sep, resource = s.partition("/")
|
||||||
|
if not sep:
|
||||||
|
resource = None
|
||||||
|
localpart, sep, domain = bare.partition("@")
|
||||||
|
if not sep:
|
||||||
|
domain = localpart
|
||||||
|
localpart = None
|
||||||
|
return localpart, domain, resource
|
||||||
|
|
||||||
|
|
||||||
|
def raise_iq_error(err: ET.Element):
|
||||||
|
err_condition_el = None
|
||||||
|
err_text_el = None
|
||||||
|
err_app_def_condition_el = None
|
||||||
|
|
||||||
|
for el in err:
|
||||||
|
if el.tag == TAG_XMPP_ERROR_TEXT:
|
||||||
|
err_text_el = el
|
||||||
|
elif el.tag.startswith("{{{}}}".format(NS_XMPP_ERROR_CONDITION)):
|
||||||
|
err_condition_el = el
|
||||||
|
else:
|
||||||
|
err_app_def_condition_el = el
|
||||||
|
|
||||||
|
abort(ERROR_CODE_MAP.get(err_condition_el.tag, 500),
|
||||||
|
err_condition_el.tag)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_iq_reply(tree: ET.Element,
|
||||||
|
require_tag: str = None) -> typing.Optional[ET.Element]:
|
||||||
|
iq_type = tree.get("type")
|
||||||
|
if iq_type == "error":
|
||||||
|
error = tree.find(TAG_XMPP_ERROR)
|
||||||
|
if error is not None:
|
||||||
|
raise raise_iq_error(error)
|
||||||
|
raise abort(500, "malformed reply")
|
||||||
|
elif iq_type == "result":
|
||||||
|
if len(tree) > 0:
|
||||||
|
reply_el = tree[0]
|
||||||
|
if require_tag and reply_el.tag != require_tag:
|
||||||
|
raise abort(500, "unexpected reply")
|
||||||
|
return reply_el
|
||||||
|
if require_tag:
|
||||||
|
raise abort(500, "unexpected reply")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
raise abort(500, "unsupported reply")
|
||||||
|
|
||||||
|
|
||||||
|
def make_password_change_request(jid, password):
|
||||||
|
username, domain, _ = split_jid(jid)
|
||||||
|
# XXX: this is due to a problem with mod_rest / mod_register in prosody:
|
||||||
|
# it doesn’t recognize the password change stanza unless we send it to
|
||||||
|
# the account JID.
|
||||||
|
req = ET.Element("iq", to="{}@{}".format(username, domain), type="set")
|
||||||
|
q = ET.SubElement(req, "query", xmlns="jabber:iq:register")
|
||||||
|
ET.SubElement(q, "username").text = username
|
||||||
|
ET.SubElement(q, "password").text = password
|
||||||
|
return ET.tostring(req)
|
||||||
|
|
||||||
|
|
||||||
|
def make_nickname_set_request(to, nickname):
|
||||||
|
req = ET.Element("iq", type="set", to=to)
|
||||||
|
q = ET.SubElement(req, "pubsub", xmlns="http://jabber.org/protocol/pubsub")
|
||||||
|
publish = ET.SubElement(q, "publish", node="http://jabber.org/protocol/nick")
|
||||||
|
item = ET.SubElement(publish, "item")
|
||||||
|
ET.SubElement(
|
||||||
|
item,
|
||||||
|
"nick",
|
||||||
|
xmlns="http://jabber.org/protocol/nick"
|
||||||
|
).text = nickname
|
||||||
|
|
||||||
|
return ET.tostring(req)
|
||||||
|
|
||||||
|
|
||||||
|
def make_nickname_get_request(to):
|
||||||
|
req = ET.Element("iq", type="get", to=to)
|
||||||
|
q = ET.SubElement(req, "pubsub", xmlns="http://jabber.org/protocol/pubsub")
|
||||||
|
items = ET.SubElement(q, "items", node="http://jabber.org/protocol/nick", max_items="1")
|
||||||
|
|
||||||
|
return ET.tostring(req)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_child(t: ET.Element, tag: str) -> ET.Element:
|
||||||
|
el = t.find(tag)
|
||||||
|
if el is None:
|
||||||
|
raise abort(500, "malformed reply")
|
||||||
|
return el
|
||||||
|
|
||||||
|
|
||||||
|
def extract_nickname_get_reply(iq_tree):
|
||||||
|
try:
|
||||||
|
pubsub = extract_iq_reply(iq_tree, TAG_PUBSUB)
|
||||||
|
except quart.exceptions.NotFound:
|
||||||
|
return None
|
||||||
|
|
||||||
|
items = _require_child(pubsub, TAG_PUBSUB_ITEMS)
|
||||||
|
if len(items) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
item = _require_child(items, TAG_PUBSUB_ITEM)
|
||||||
|
nick = _require_child(item, TAG_USER_NICKNAME_NICK)
|
||||||
|
return nick.text
|
||||||
Reference in New Issue
Block a user