From 9b34b3348ad911320195301317eac20f9a5fdb54 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Thu, 23 Jan 2025 18:35:20 +0100 Subject: [PATCH 01/35] DSN: Reprezentace v DB --- db/db.ddl | 21 +++++++++++++++++++++ db/upgrade-20250123.sql | 21 +++++++++++++++++++++ mo/db.py | 21 +++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 db/upgrade-20250123.sql diff --git a/db/db.ddl b/db/db.ddl index 8fa36b32..72df2e91 100644 --- a/db/db.ddl +++ b/db/db.ddl @@ -26,6 +26,7 @@ CREATE TABLE users ( password_hash varchar(255) DEFAULT NULL, -- heš hesla (je-li nastaveno) note text NOT NULL DEFAULT '', -- poznámka viditelná pro orgy email_notify boolean NOT NULL DEFAULT true -- přeje si dostávat mailové notifikace + dsn_id int DEFAULT NULL, -- mailová nedoručenka (REFERENCES později) ); -- Uživatel s user_id=0 vždy existuje a je to systémový uživatel s právy admina. @@ -166,6 +167,7 @@ CREATE TABLE contests ( place_id int NOT NULL REFERENCES places(place_id), state round_state NOT NULL DEFAULT 'preparing', -- používá se, pokud round.state='delegate', jinak kopíruje round.state scoretable_id int DEFAULT NULL, -- odkaz na snapshot představující oficiální výsledkovou listinu soutěže + -- (REFERENCES později) tex_hacks text NOT NULL DEFAULT '', -- speciální nastavení pro sazbu výsledkovky online_submit boolean NOT NULL DEFAULT true, -- účastníkům je povoleno elektronické odevzdávání UNIQUE (round_id, place_id) @@ -415,6 +417,7 @@ CREATE TABLE reg_requests ( captcha_token varchar(255) DEFAULT NULL, -- token pro fázi 1 registrace email_token varchar(255) UNIQUE NOT NULL, -- token pro fázi 2 registrace user_id int DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE, + dsn_id int DEFAULT NULL, -- mailová nedoručenka (REFERENCES později) client varchar(255) NOT NULL -- kdo si registraci vyžádal ); @@ -506,3 +509,21 @@ CREATE TABLE sent_email ( sent_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, key) ); + +-- Mailové nedoručenky (Delivery Status Notifications) + +CREATE TABLE email_dsns ( + dsn_id serial PRIMARY KEY, + token text UNIQUE NOT NULL, + user_id int DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE , + reg_id int DEFAULT NULL REFERENCES reg_requests(reg_id) ON DELETE CASCADE, + arrived_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + message_id text DEFAULT NULL, -- MessageID nedoručenky + status text DEFAULT NULL, -- SMTP enhanced status code (e.g., 4.1.1) + remote_mta text DEFAULT NULL, -- IP adresa nebo doménové jméno odesilatele DSN + diag_code text DEFAULT NULL, -- zdůvodnění od odesilatele DSN + CHECK (user_id IS NOT NULL OR reg_id IS NOT NULL) +); + +ALTER TABLE reg_requests ADD CONSTRAINT "reg_requests_dsn_id" FOREIGN KEY (dsn_id) REFERENCES email_dsns(dsn_id) ON DELETE SET NULL; +ALTER TABLE users ADD CONSTRAINT "users_dsn_id" FOREIGN KEY (dsn_id) REFERENCES email_dsns(dsn_id) ON DELETE SET NULL; diff --git a/db/upgrade-20250123.sql b/db/upgrade-20250123.sql new file mode 100644 index 00000000..bb93a480 --- /dev/null +++ b/db/upgrade-20250123.sql @@ -0,0 +1,21 @@ +ALTER TABLE users ADD COLUMN + dsn_id int DEFAULT NULL; + +ALTER TABLE reg_requests ADD COLUMN + dsn_id int DEFAULT NULL; + +CREATE TABLE email_dsns ( + dsn_id serial PRIMARY KEY, + token text UNIQUE NOT NULL, + user_id int DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE , + reg_id int DEFAULT NULL REFERENCES reg_requests(reg_id) ON DELETE CASCADE, + arrived_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + message_id text DEFAULT NULL, -- MessageID nedoručenky + status text DEFAULT NULL, -- SMTP extended status code (e.g., 4.1.1) + remote_mta text DEFAULT NULL, -- IP adresa nebo doménové jméno odesilatele DSN + diag_code text DEFAULT NULL, -- zdůvodnění od odesilatele DSN + CHECK (user_id IS NOT NULL OR reg_id IS NOT NULL) +); + +ALTER TABLE reg_requests ADD CONSTRAINT "reg_requests_dsn_id" FOREIGN KEY (dsn_id) REFERENCES email_dsns(dsn_id) ON DELETE SET NULL; +ALTER TABLE users ADD CONSTRAINT "users_dsn_id" FOREIGN KEY (dsn_id) REFERENCES email_dsns(dsn_id) ON DELETE SET NULL; diff --git a/mo/db.py b/mo/db.py index 4e2dfe2a..d11cce41 100644 --- a/mo/db.py +++ b/mo/db.py @@ -441,9 +441,11 @@ class User(Base): password_hash = Column(String(255), server_default=text("NULL::character varying")) note = Column(Text, nullable=False, server_default=text("''::text")) email_notify = Column(Boolean, nullable=False, server_default=text("true")) + dsn_id = Column(Integer, ForeignKey('email_dsns.dsn_id')) roles = relationship('UserRole', primaryjoin='UserRole.user_id == User.user_id', back_populates='user') participants = relationship('Participant', primaryjoin='Participant.user_id == User.user_id', back_populates='user') + dsn = relationship('EmailDSN', primaryjoin='EmailDSN.dsn_id == User.dsn_id', back_populates='user', viewonly=True) def full_name(self) -> str: return self.first_name + ' ' + self.last_name @@ -932,9 +934,11 @@ class RegRequest(Base): email = Column(Text) email_token = Column(Text, nullable=False, unique=True) user_id = Column(Integer, ForeignKey('users.user_id')) + dsn_id = Column(Integer, ForeignKey('email_dsns.dsn_id')) client = Column(Text, nullable=False) user = relationship('User') + dsn = relationship('EmailDSN', primaryjoin='EmailDSN.dsn_id == RegRequest.dsn_id') class RegionDescendant(Base): @@ -1045,6 +1049,23 @@ class SentEmail(Base): user = relationship('User') +class EmailDSN(Base): + __tablename__ = 'email_dsns' + + dsn_id = Column(Integer, primary_key=True, server_default=text("nextval('email_dsn_dsn_id_seq'::regclass)")) + token = Column(Text, nullable=False, unique=True) + user_id = Column(Integer, ForeignKey('users.user_id')) + reg_id = Column(Integer, ForeignKey('reg_requests.reg_id')) + arrived_at = Column(DateTime(True), nullable=False, server_default=text("CURRENT_TIMESTAMP")) + message_id = Column(Text) + status = Column(Text) + remote_mta = Column(Text) + diag_code = Column(Text) + + user = relationship('User', primaryjoin='User.user_id == EmailDSN.user_id') + reg = relationship('RegRequest', primaryjoin='RegRequest.reg_id == EmailDSN.reg_id') + + _engine: Optional[Engine] = None _session: Optional[Session] = None flask_db: Any = None -- GitLab From af227f4851375bf6986b1036b5726a060d75b175 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Thu, 23 Jan 2025 18:35:30 +0100 Subject: [PATCH 02/35] =?UTF-8?q?DSN:=20Ukl=C3=A1d=C3=A1n=C3=AD=20do=20DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/api_dsn.py | 80 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 62 insertions(+), 18 deletions(-) diff --git a/mo/web/api_dsn.py b/mo/web/api_dsn.py index 64de762c..99194148 100644 --- a/mo/web/api_dsn.py +++ b/mo/web/api_dsn.py @@ -10,29 +10,32 @@ from flask import request, Response from flask.json import jsonify import re from typing import Optional +from unidecode import unidecode import werkzeug.exceptions import mo.config as config +import mo.db as db import mo.email import mo.util_format from mo.web import app -class DSN: +class DSNParser: msg: email.message.EmailMessage - message_id: str verdict: str + parsed: db.EmailDSN dsn_action: str - dsn_status: str def __init__(self, body: bytes): self.msg = email.message_from_bytes(body, policy=email.policy.default) # FIXME: Types - self.message_id = (self.msg['Message-Id'] or '?').strip() + self.parsed = db.EmailDSN() + self.parsed.message_id = (self.msg['Message-Id'] or '?').strip() self.dsn_action = '?' - self.dsn_status = '?' self.verdict = self.parse_dsn() def parse_dsn(self) -> str: + """Parse DSN as specified by RFC 3464.""" + if self.msg.get_content_type() != 'multipart/report': return 'unexpected content-type' @@ -56,9 +59,25 @@ class DSN: # main = dsn[0] - per_addr = dsn[1] + per_addr: email.message.EmailMessage = dsn[1] self.dsn_action = per_addr.get('Action', '?') - self.dsn_status = per_addr.get('Status', '?') + + status = per_addr.get('Status') + if status and re.fullmatch(r'\d\.\d\.\d{1,8}', status): + self.parsed.status = status + + remote_mta = per_addr.get('Remote-MTA') or per_addr.get('Reporting-MTA') + if remote_mta and (rm := re.fullmatch(r'dns;\s+([!-~]{1,256})', remote_mta, re.IGNORECASE)): + self.parsed.remote_mta = rm[1] + + diag_code = per_addr.get('Diagnostic-Code') + if diag_code and (dm := re.fullmatch(r'smtp;\s+(.*)', diag_code, re.IGNORECASE)): + dc = unidecode(dm[1]) # Mělo by to být v ASCII, ale pro jistotu... + dc = re.sub(r'\s{2,}', ' ', dc) + MAX_DIAG_LEN = 1000 + if len(dc) > MAX_DIAG_LEN: + dc = dc[:MAX_DIAG_LEN] + " [...]" + self.parsed.diag_code = dc if self.dsn_action != 'failed': return 'not failed' @@ -80,39 +99,64 @@ class DSN: return None +def process_dsn_user(dsn: db.EmailDSN) -> None: + dsn.user.dsn_id = dsn.dsn_id + + +def process_dsn_reg(dsn: db.EmailDSN) -> None: + if (dsn.reg.user_id is not None + and dsn.user_id is not None + and dsn.reg.user_id != dsn.user_id): + app.logger.warning('DSN: Nesouhlasí user_id s registrací') + + dsn.reg.dsn_id = dsn.dsn_id + + @app.route('/api/email-dsn', methods=('POST',)) def api_email_dsn() -> Response: - # FIXME: Authorization? - # FIXME: Add in Flask 3.1: request.max_content_length = 1048576 body = request.get_data(cache=False) try: - dsn = DSN(body) + parser = DSNParser(body) + dsn = parser.parsed except MessageError as e: app.logger.info(f'DSN: Nemohu naparsovat zprávu: {e}') raise werkzeug.exceptions.UnprocessableEntity() app.logger.info(f'DSN: Message-ID: {dsn.message_id}') - app.logger.info(f'DSN: Parse: action={dsn.dsn_action} status={dsn.dsn_status} -> {dsn.verdict}') + app.logger.info(f'DSN: Parse: action={parser.dsn_action} status={dsn.status or "-"} -> {parser.verdict}') - if dsn.verdict == 'not failed': + if parser.verdict == 'not failed': return jsonify({}) - elif dsn.verdict != 'ok': + elif parser.verdict != 'ok': raise werkzeug.exceptions.UnprocessableEntity() - if not (token := dsn.find_token()): + if not (token := parser.find_token()): app.logger.info('DSN: Token not found') raise werkzeug.exceptions.UnprocessableEntity() app.logger.info(f'DSN: Token: {token}') try: - user, rr, email, when = mo.email.validate_dsn_token(token) - user_info = f'#{user.user_id}' if user is not None else '-' - rr_info = f'#{rr.reg_id}' if rr is not None else '-' + dsn.token = token + dsn.user, dsn.reg, email, when = mo.email.validate_dsn_token(token) + user_info = f'#{dsn.user.user_id}' if dsn.user is not None else '-' + rr_info = f'#{dsn.reg.reg_id}' if dsn.reg is not None else '-' age = mo.util_format.time_duration_numeric(mo.now - when) app.logger.info(f'DSN: user={user_info} registration={rr_info} email={email} age={age}') except ValueError as e: app.logger.info(f'DSN: {e}') - pass + raise werkzeug.exceptions.UnprocessableEntity() + + sess = db.get_session() + if sess.query(db.EmailDSN).filter_by(token=token).one_or_none(): + app.logger.info('DSN: Already known') + else: + sess.add(dsn) + sess.flush() # aby dsn získala dsn_id + if dsn.reg: + process_dsn_reg(dsn) + elif dsn.user: + process_dsn_user(dsn) + sess.commit() return jsonify({}) -- GitLab From b3eba8b82b292d625146f816690721558f50b535 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Fri, 24 Jan 2025 23:36:37 +0100 Subject: [PATCH 03/35] =?UTF-8?q?user=5Furl=20p=C5=99esunuto=20do=20mo.web?= =?UTF-8?q?.util?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/jinja.py | 12 +++--------- mo/web/util.py | 7 +++++++ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/mo/web/jinja.py b/mo/web/jinja.py index 96515a7c..2a890c51 100644 --- a/mo/web/jinja.py +++ b/mo/web/jinja.py @@ -17,7 +17,7 @@ from mo.util import assert_not_none import mo.util_format as util_format from mo.web import app from mo.web.org_place import place_breadcrumbs -from mo.web.util import user_html_flags +from mo.web.util import user_html_flags, user_url # Konfigurace Jinjy @@ -69,20 +69,14 @@ jg.update(Right=Right) jg.update(place_breadcrumbs=place_breadcrumbs) # Funkce asset_url se přidává v mo.ext.assets +jf.update(user_url=user_url) + @app.template_filter() def user_link(u: db.User) -> Markup: return Markup('<a href="{url}">{name}{test}</a>').format(url=user_url(u), name=u.full_name(), test=" (test)" if u.is_test else "") -@app.template_filter() -def user_url(u: db.User) -> str: - if u.is_admin or u.is_org: - return url_for('org_org', id=u.user_id) - else: - return url_for('org_user', id=u.user_id) - - @app.template_filter() def place_link(p: db.Place) -> Markup: return Markup('<a href="{url}">{name}</a>').format(url=place_url(p), name=p.name) diff --git a/mo/web/util.py b/mo/web/util.py index 82a43235..63c4bb50 100644 --- a/mo/web/util.py +++ b/mo/web/util.py @@ -152,3 +152,10 @@ def user_html_flags(u: db.User) -> Markup: if len(r) == 0: return Markup("") return Markup(" " + "".join(r)) + + +def user_url(u: db.User) -> str: + if u.is_admin or u.is_org: + return url_for('org_org', id=u.user_id) + else: + return url_for('org_user', id=u.user_id) -- GitLab From 9be1db318f7ee28e4347619143691b135da64734 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Fri, 24 Jan 2025 23:37:07 +0100 Subject: [PATCH 04/35] =?UTF-8?q?Symbol=20k=20=C3=BA=C4=8Dtu=20pro=20nedor?= =?UTF-8?q?u=C4=8Diteln=C3=BD=20mail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/util.py | 2 ++ static/mo.css | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/mo/web/util.py b/mo/web/util.py index 63c4bb50..d7987db5 100644 --- a/mo/web/util.py +++ b/mo/web/util.py @@ -147,6 +147,8 @@ def user_html_flags(u: db.User) -> Markup: r = [] if u.is_test: r.append("<span class='user-test' title='Testovací uživatel'>*</span>") + if u.dsn: + r.append("<span class='user-bad-email' title='E-mailová adresa nefunguje'>🖂</span>") if u.is_inactive: r.append("<span class='user-inactive' title='Účet dosud nebyl aktivován'>*</span>") if len(r) == 0: diff --git a/static/mo.css b/static/mo.css index ce1867ab..58864bb1 100644 --- a/static/mo.css +++ b/static/mo.css @@ -322,10 +322,15 @@ table.data tbody tr.job-waiting:hover { .user-test { color: green; } + .user-inactive { color: red; } +.user-bad-email { + color: red; +} + /* Alerts */ /* Multiple alerts displayed in more compact way */ -- GitLab From 6ee07ecc395f9655580be74b3ed38b37d2e09ca9 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Fri, 24 Jan 2025 23:43:58 +0100 Subject: [PATCH 05/35] =?UTF-8?q?DSN:=20Funkce=20na=20reset=20v=C5=A1ech?= =?UTF-8?q?=20DSN=20k=20=C3=BA=C4=8Dtu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/users.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/mo/users.py b/mo/users.py index 2b702c70..98bfa52f 100644 --- a/mo/users.py +++ b/mo/users.py @@ -467,3 +467,12 @@ def request_reset_password(user: db.User, client: str) -> Optional[db.RegRequest details={'action': 'ask-reset'}, ) return rr + + +def reset_user_dsn(user: db.User) -> None: + if user.dsn_id: + logger.info(f'Uživatel #{user.user_id}: Reset DSN') + user.dsn_id = None + conn = db.get_session().connection() + dsn_table = db.EmailDSN.__table__ + conn.execute(dsn_table.delete().where(dsn_table.c.user_id == user.user_id)) -- GitLab From 9aef86dbe8896e0c22c44aa2e0cb6940dd006edf Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Fri, 24 Jan 2025 23:44:30 +0100 Subject: [PATCH 06/35] =?UTF-8?q?request.client=20m=C5=AF=C5=BEe=20b=C3=BD?= =?UTF-8?q?t=20None?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pravděpodobně se to nikdy nestane s naším serverem, ale type checker si stěžuje. --- mo/users.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mo/users.py b/mo/users.py index 98bfa52f..0754725c 100644 --- a/mo/users.py +++ b/mo/users.py @@ -427,7 +427,7 @@ def check_activation_token(token: str) -> Optional[db.User]: return user -def new_reg_request(type: db.RegReqType, client: str) -> Optional[db.RegRequest]: +def new_reg_request(type: db.RegReqType, client: Optional[str]) -> Optional[db.RegRequest]: sess = db.get_session() # Zatím jen jednoduchý rate limit, časem možno vylepšit @@ -442,7 +442,7 @@ def new_reg_request(type: db.RegReqType, client: str) -> Optional[db.RegRequest] created_at=mo.now, expires_at=mo.now + datetime.timedelta(minutes=config.REG_TOKEN_VALIDITY), email_token=email_token, - client=client, + client=client or 'unknown', ) @@ -454,7 +454,7 @@ def expire_reg_requests(): sess.commit() -def request_reset_password(user: db.User, client: str) -> Optional[db.RegRequest]: +def request_reset_password(user: db.User, client: Optional[str]) -> Optional[db.RegRequest]: logger.info('Login: Požadavek na reset hesla pro <%s>', user.email) assert not user.is_admin rr = new_reg_request(db.RegReqType.reset_passwd, client) -- GitLab From 4e08962419880c54fe0293438710e1cf60d6cc34 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Fri, 24 Jan 2025 23:45:34 +0100 Subject: [PATCH 07/35] =?UTF-8?q?DSN:=20Reset,=20pokud=20org=20m=C4=9Bn?= =?UTF-8?q?=C3=AD=20=C3=BA=C4=8Dtu=20e-mail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/org_users.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mo/web/org_users.py b/mo/web/org_users.py index 11e0240d..fdf5587f 100644 --- a/mo/web/org_users.py +++ b/mo/web/org_users.py @@ -524,8 +524,9 @@ def org_user_edit(id: int): form.populate_obj(user) if sess.is_modified(user): changes = db.get_object_changes(user) - - app.logger.info(f"User {id} modified, changes: {changes}") + if 'email' in changes: + mo.users.reset_user_dsn(user) + app.logger.info(f"Uživatel #{id} změněn: {changes}") mo.util.log( type=db.LogType.user, what=id, -- GitLab From 7c20f4e776c45c12eaf4fe2ad56806a20c9e7e3c Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Fri, 24 Jan 2025 23:49:10 +0100 Subject: [PATCH 08/35] =?UTF-8?q?DSN:=20Org=20si=20m=C5=AF=C5=BEe=20nechat?= =?UTF-8?q?=20zobrazit=20nedoru=C4=8Denky=20k=20=C3=BA=C4=8Dtu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/org_users.py | 20 ++++++++++++++++++++ mo/web/templates/org_user_dsn.html | 24 ++++++++++++++++++++++++ mo/web/templates/parts/dsn.html | 28 ++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 mo/web/templates/org_user_dsn.html create mode 100644 mo/web/templates/parts/dsn.html diff --git a/mo/web/org_users.py b/mo/web/org_users.py index fdf5587f..43f104b0 100644 --- a/mo/web/org_users.py +++ b/mo/web/org_users.py @@ -823,3 +823,23 @@ def org_user_delete(user_id: int): return redirect(url_for('org_orgs') if user.is_org else url_for('org_users')) return render_template('org_user_delete.html', user=user, form=form, warnings=warnings, errors=errors) + + +@app.route('/org/user/<int:user_id>/dsn') +@app.route('/org/org/<int:user_id>/dsn', endpoint='org_org_dsn') +def org_user_dsn(user_id: int): + sess = db.get_session() + user = mo.users.user_by_uid(user_id) + if not user: + raise werkzeug.exceptions.NotFound() + + rr = g.gatekeeper.rights_generic() + if not rr.can_view_user(user): + raise werkzeug.exceptions.Forbidden() + + dsns = (sess.query(db.EmailDSN) + .filter_by(user=user) + .order_by(db.EmailDSN.arrived_at.desc()) + .all()) + + return render_template('org_user_dsn.html', user=user, dsns=dsns) diff --git a/mo/web/templates/org_user_dsn.html b/mo/web/templates/org_user_dsn.html new file mode 100644 index 00000000..f766a824 --- /dev/null +++ b/mo/web/templates/org_user_dsn.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Nedoručenky pro {{ user.email }}{% endblock %} +{% block body %} + +{% if dsns %} + +<p> + Tuto e-mailovou adresu považujeme za nefunkční, protože pošta na ni poslaná + se vrátila jako nedoručitelná. Zde jsou zprávy o nedoručitelnosti e-mailů z nedávné doby. +</p> + +{% include "parts/dsn.html" %} + +{% else %} + +<p><em>Momentálně neevidujeme žádnou nedoručitelnou poštu.</em></p> + +{% endif %} + +<div class="btn-group"> + <a class='btn btn-primary' href="{{ user|user_url }}">Zpět na stránku uživatele</a> +</div> + +{% endblock %} diff --git a/mo/web/templates/parts/dsn.html b/mo/web/templates/parts/dsn.html new file mode 100644 index 00000000..5feed019 --- /dev/null +++ b/mo/web/templates/parts/dsn.html @@ -0,0 +1,28 @@ +{# Výpis mailových nedoručenek #} + +{% for dsn in dsns %} + +<table class=data> + <tr> + <th>Čas + <td>{{ dsn.arrived_at|time_and_timedelta }} + <tr> + <th>Kód chyby + <td>{{ dsn.status }} + <tr> + <th>Chybová zpráva + <td>{{ dsn.diag_code|or_dash }} + <tr> + <th>Vzdálený server + <td>{{ dsn.remote_mta|or_dash }} + {% if g.user and g.user.is_admin %} + <tr> + <th>DSN token + <td>{{ dsn.token|or_dash }} + <tr> + <th>DSN Message-ID + <td>{{ dsn.message_id|or_dash }} + {% endif %} +</table> + +{% endfor %} -- GitLab From 79738749c60e3cd15dd61c1932d2302c3c922cc0 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Fri, 24 Jan 2025 23:50:48 +0100 Subject: [PATCH 09/35] =?UTF-8?q?DSN:=20Odkazy=20z=20orgovsk=C3=A9=20spr?= =?UTF-8?q?=C3=A1vy=20u=C5=BEivatel=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/templates/org_org.html | 3 +++ mo/web/templates/org_user.html | 3 +++ mo/web/templates/org_user_edit.html | 6 +++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/mo/web/templates/org_org.html b/mo/web/templates/org_org.html index beb75788..b2b96baa 100644 --- a/mo/web/templates/org_org.html +++ b/mo/web/templates/org_org.html @@ -7,6 +7,9 @@ <tr><td>Jméno:<td>{{ user.first_name }} <tr><td>Příjmení:<td>{{ user.last_name }} <tr><td>E-mail:<td>{{ user.email|mailto }} + {% if user.dsn %} + <a class="btn btn-xs btn-danger" href="{{ url_for('org_org_dsn', user_id=user.user_id) }}">nedoručitelný</a> + {% endif %} {% if user.is_admin %}<tr><td>Správce:<td>ano{% endif %} {% if user.is_org %}<tr><td>Organizátor:<td>ano{% endif %} <tr><td>Účet založen:<td>{{ user.created_at|timeformat }} diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html index 313a2569..30121148 100644 --- a/mo/web/templates/org_user.html +++ b/mo/web/templates/org_user.html @@ -7,6 +7,9 @@ <tr><td>Jméno:<td>{{ user.first_name }} <tr><td>Příjmení:<td>{{ user.last_name }} <tr><td>E-mail:<td>{{ user.email|mailto }} + {% if user.dsn %} + <a class="btn btn-xs btn-danger" href="{{ url_for('org_user_dsn', user_id=user.user_id) }}">nedoručitelný</a> + {% endif %} {% if user.is_admin %}<tr><td>Správce:<td>ano{% endif %} {% if user.is_org %}<tr><td>Organizátor:<td>ano{% endif %} <tr><td>Účet založen:<td>{{ user.created_at|timeformat }} diff --git a/mo/web/templates/org_user_edit.html b/mo/web/templates/org_user_edit.html index 4d78b077..529f49da 100644 --- a/mo/web/templates/org_user_edit.html +++ b/mo/web/templates/org_user_edit.html @@ -6,7 +6,11 @@ <table class=data> <tr><td>Jméno:</td><td>{{ user.first_name }}</td></tr> <tr><td>Příjmení:</td><td>{{ user.last_name }}</td></tr> -<tr><td>E-mail:</td><td>{{ user.email|mailto }}</td></tr> +<tr><td>E-mail:</td><td>{{ user.email|mailto }} + {% if user.dsn %} + <a class="btn btn-xs btn-danger" href="{{ url_for('org_user_dsn', user_id=user.user_id) }}">nedoručitelný</a> + {% endif %} + </td></tr> {% if user.is_admin %}<tr><td>Správce:</td><td>ano</td></tr>{% endif %} {% if user.is_org %}<tr><td>Organizátor:</td><td>ano</td></tr>{% endif %} <tr><td>Poznámka:</td><td style="white-space: pre-line;">{{ user.note }}</td></tr> -- GitLab From 99bd1da6a61763d3a7d54200bcd216cd1b55b351 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Fri, 24 Jan 2025 23:51:42 +0100 Subject: [PATCH 10/35] =?UTF-8?q?DSN:=20Na=20d=C5=AFle=C5=BEit=C3=BDch=20s?= =?UTF-8?q?tr=C3=A1nk=C3=A1ch=20upozor=C5=88ujeme=20na=20nefunk=C4=8Dnost?= =?UTF-8?q?=20e-mailu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vesměs to jsou vstupní body, ale také settings. --- mo/web/templates/org_index.html | 2 ++ mo/web/templates/parts/check_dsn.html | 13 +++++++++++++ mo/web/templates/settings.html | 2 ++ mo/web/templates/user_index.html | 2 ++ mo/web/templates/user_join_list.html | 2 ++ 5 files changed, 21 insertions(+) create mode 100644 mo/web/templates/parts/check_dsn.html diff --git a/mo/web/templates/org_index.html b/mo/web/templates/org_index.html index d14d84b7..a0229652 100644 --- a/mo/web/templates/org_index.html +++ b/mo/web/templates/org_index.html @@ -3,6 +3,8 @@ {% block title %}Přístup pro organizátory{% endblock %} {% block body %} +{% include "parts/check_dsn.html" %} + {% if overview %} <h3>Moje soutěže</h3> diff --git a/mo/web/templates/parts/check_dsn.html b/mo/web/templates/parts/check_dsn.html new file mode 100644 index 00000000..d32bb5d2 --- /dev/null +++ b/mo/web/templates/parts/check_dsn.html @@ -0,0 +1,13 @@ +{% if g.user.dsn %} + +<div class="alert alert-danger"> + <p>E-mailová adresa spojená s tímto účtem není funkční – pošta se vrací jako nedoručitelná.</p> + + <p> + <a class='btn btn-xs btn-primary' href='{{ url_for('user_validate_email') }}'>Ověřit e-mail</a> + <a class='btn btn-xs btn-primary' href='{{ url_for('user_settings_personal') }}'>Změnit e-mail</a> + <a class='btn btn-xs btn-default' href='{{ url_for('user_dsn') }}'>Detaily problémů</a> + </p> +</div> + +{% endif %} diff --git a/mo/web/templates/settings.html b/mo/web/templates/settings.html index 50335f12..14350ca3 100644 --- a/mo/web/templates/settings.html +++ b/mo/web/templates/settings.html @@ -3,6 +3,8 @@ {% block title %}Uživatel {{ user.full_name() }}{% endblock %} {% block body %} +{% include "parts/check_dsn.html" %} + <h3>Osobní údaje</h3> <table class=table> diff --git a/mo/web/templates/user_index.html b/mo/web/templates/user_index.html index ad3bf137..46d89241 100644 --- a/mo/web/templates/user_index.html +++ b/mo/web/templates/user_index.html @@ -2,6 +2,8 @@ {% block title %}Vítejte{% endblock %} {% block body %} +{% include "parts/check_dsn.html" %} + {% if pions %} <p>Účastníte se následujících kol MO: diff --git a/mo/web/templates/user_join_list.html b/mo/web/templates/user_join_list.html index d6af6b73..c45c8e62 100644 --- a/mo/web/templates/user_join_list.html +++ b/mo/web/templates/user_join_list.html @@ -2,6 +2,8 @@ {% block title %}Přihláška do MO{% endblock %} {% block body %} +{% include "parts/check_dsn.html" %} + {% if available_rounds %} <p>Zde si můžete vybrat, do kterých kategorií olympiády se přihlásíte. -- GitLab From e6c88ee5e5d985d3728e9c0d6f0e0e73a09781cd Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Fri, 24 Jan 2025 23:53:11 +0100 Subject: [PATCH 11/35] =?UTF-8?q?DSN:=20U=C5=BEivatel=20si=20m=C5=AF=C5=BE?= =?UTF-8?q?e=20zobrazit=20sv=C3=A9=20nedoru=C4=8Denky?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/acct.py | 15 +++++++++++++++ mo/web/templates/acct_dsn.html | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 mo/web/templates/acct_dsn.html diff --git a/mo/web/acct.py b/mo/web/acct.py index 02c82928..01f001cc 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -674,6 +674,21 @@ def handle_forbidden(e): return render_template('forbidden.html'), 403 +@app.route('/acct/dsn') +def user_dsn(): + sess = db.get_session() + user = g.user + if not user: + raise NeedLoginError() + + dsns = (sess.query(db.EmailDSN) + .filter_by(user=user) + .order_by(db.EmailDSN.arrived_at.desc()) + .all()) + + return render_template('acct_dsn.html', user=user, dsns=dsns) + + if getattr(config, 'INSECURE_TEST_LOGIN', False): @app.route('/test-login/<email>') def test_login(email: str): diff --git a/mo/web/templates/acct_dsn.html b/mo/web/templates/acct_dsn.html new file mode 100644 index 00000000..ba0d4a38 --- /dev/null +++ b/mo/web/templates/acct_dsn.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Nedoručitelné e-maily{% endblock %} +{% block body %} + +{% if dsns %} + +<p> + Tuto e-mailovou adresu považujeme za nefunkční, protože pošta na ni poslaná + se vrátila jako nedoručitelná. Zde jsou zprávy o nedoručitelnosti e-mailů z nedávné doby. +</p> + +{% include "parts/dsn.html" %} + +{% else %} + +<p><em>Momentálně neevidujeme žádnou nedoručitelnou poštu.</em></p> + +{% endif %} + +<div class="btn-group"> + <a class='btn btn-primary' href='{{ url_for('user_validate_email') }}'>Ověřit e-mail</a> + <a class='btn btn-primary' href='{{ url_for('user_settings_personal') }}'>Změnit e-mail</a> + <a class='btn btn-default' href="{{ url_for('user_settings') }}">Zpět na nastavení účtu</a> +</div> + +{% endblock %} -- GitLab From 731251451b83e991ba35081457d4473be3c60d11 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Fri, 24 Jan 2025 23:53:45 +0100 Subject: [PATCH 12/35] =?UTF-8?q?DB:=20Nov=C3=BD=20typ=20registra=C4=8Dn?= =?UTF-8?q?=C3=ADho=20po=C5=BEadavku=20pro=20validaci=20e-mailu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/db.ddl | 3 ++- db/upgrade-20250123.sql | 2 ++ mo/db.py | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/db/db.ddl b/db/db.ddl index 72df2e91..7d2775a8 100644 --- a/db/db.ddl +++ b/db/db.ddl @@ -404,7 +404,8 @@ CREATE TABLE messages ( CREATE TYPE reg_req_type AS ENUM ( 'register', 'change_email', - 'reset_passwd' + 'reset_passwd', + 'validate_email' ); CREATE TABLE reg_requests ( diff --git a/db/upgrade-20250123.sql b/db/upgrade-20250123.sql index bb93a480..9ca7e586 100644 --- a/db/upgrade-20250123.sql +++ b/db/upgrade-20250123.sql @@ -19,3 +19,5 @@ CREATE TABLE email_dsns ( ALTER TABLE reg_requests ADD CONSTRAINT "reg_requests_dsn_id" FOREIGN KEY (dsn_id) REFERENCES email_dsns(dsn_id) ON DELETE SET NULL; ALTER TABLE users ADD CONSTRAINT "users_dsn_id" FOREIGN KEY (dsn_id) REFERENCES email_dsns(dsn_id) ON DELETE SET NULL; + +ALTER TYPE reg_req_type ADD VALUE 'validate_email'; diff --git a/mo/db.py b/mo/db.py index d11cce41..185da8e1 100644 --- a/mo/db.py +++ b/mo/db.py @@ -920,6 +920,7 @@ class RegReqType(MOEnum): register = auto() change_email = auto() reset_passwd = auto() + validate_email = auto() class RegRequest(Base): -- GitLab From 4931984f7784fd6732169608a6502752811c4c54 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Fri, 24 Jan 2025 23:55:42 +0100 Subject: [PATCH 13/35] =?UTF-8?q?Registrace:=20=C3=9Aklid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/acct.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/mo/web/acct.py b/mo/web/acct.py index 01f001cc..af3f33d3 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -523,9 +523,7 @@ class Reg2: mo.users.set_password(user, passwd) mo.users.login(user) - rr.used_at = mo.now - - sess.commit() + self.spend_request_and_commit() self.user = user return True @@ -551,8 +549,7 @@ class Reg2: }, ) - self.rr.used_at = mo.now - sess.commit() + self.spend_request_and_commit() return True def change_passwd(self, new_passwd: str): @@ -562,11 +559,9 @@ class Reg2: app.logger.info(f'Reg2: Uživatel #{user.user_id} si resetoval heslo') mo.users.set_password(user, new_passwd, reset=True) mo.users.login(user) + self.spend_request_and_commit() - self.rr.used_at = mo.now - sess.commit() - - def spend_request(self): + def spend_request_and_commit(self): self.rr.used_at = mo.now db.get_session().commit() @@ -585,7 +580,7 @@ class Reg2Form(FlaskForm): submit = wtforms.SubmitField('Vytvořit účet') -# URL je explicitně uvedeno v mo.email.activate_url +# URL je explicitně uvedeno v mo.email.confirm_url @app.route('/acct/confirm/r', methods=('GET', 'POST')) def confirm_reg(): token = request.args.get('token') @@ -617,7 +612,7 @@ class ConfirmEmailForm(FlaskForm): cancel = wtforms.SubmitField('Zrušit požadavek') -# URL je explicitně uvedeno v mo.email.activate_url +# URL je explicitně uvedeno v mo.email.confirm_url @app.route('/acct/confirm/e', methods=('GET', 'POST')) def confirm_email(): reg2 = Reg2(request.args.get('token'), db.RegReqType.change_email) @@ -632,7 +627,7 @@ def confirm_email(): flash('E-mail změněn.', 'success') return redirect(url_for('user_settings')) elif form.cancel.data: - reg2.spend_request() + reg2.spend_request_and_commit() flash('Požadavek na změnu e-mailu zrušen.', 'success') return redirect(url_for('user_settings')) @@ -647,7 +642,7 @@ class CancelResetForm(FlaskForm): cancel = wtforms.SubmitField('Zrušit obnovu hesla') -# URL je explicitně uvedeno v mo.email.activate_url +# URL je explicitně uvedeno v mo.email.confirm_url @app.route('/acct/confirm/p', methods=('GET', 'POST')) def confirm_reset(): reg2 = Reg2(request.args.get('token'), db.RegReqType.reset_passwd) @@ -662,7 +657,7 @@ def confirm_reset(): cform = CancelResetForm() if cform.validate_on_submit() and cform.cancel.data: - reg2.spend_request() + reg2.spend_request_and_commit() flash('Požadavek na změnu hesla zrušen.', 'success') return redirect(url_for('user_settings')) -- GitLab From ce55c653eed851714c1d0c6095f6a1d71505917c Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 00:00:40 +0100 Subject: [PATCH 14/35] =?UTF-8?q?Registrace:=20Oprava=20typ=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/acct.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mo/web/acct.py b/mo/web/acct.py index af3f33d3..53d14a07 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -454,10 +454,11 @@ class Reg2: }, } - def __init__(self, token: str, expected_type: db.RegReqType): + def __init__(self, token: Optional[str], expected_type: db.RegReqType): self.reg_type = expected_type self.status = self._parse_token(token) if self.status == RegStatus.ok: + assert token is not None self.status = self._load_rr(token) def _parse_token(self, token: Optional[str]) -> RegStatus: -- GitLab From 8ac4df73a43f0a3fafe494d973ba16366e9efe51 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 00:01:51 +0100 Subject: [PATCH 15/35] =?UTF-8?q?DSN:=20Resetujeme=20p=C5=99i=20=C3=BAsp?= =?UTF-8?q?=C4=9B=C5=A1n=C3=A9=20zm=C4=9Bn=C4=9B=20e-mailu=20a=20resetu=20?= =?UTF-8?q?hesla?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tehdy uživatel právě prošel odkazem z mailu. --- mo/web/acct.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mo/web/acct.py b/mo/web/acct.py index 53d14a07..a6646d22 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -550,6 +550,7 @@ class Reg2: }, ) + mo.users.reset_user_dsn(user) self.spend_request_and_commit() return True @@ -560,6 +561,7 @@ class Reg2: app.logger.info(f'Reg2: Uživatel #{user.user_id} si resetoval heslo') mo.users.set_password(user, new_passwd, reset=True) mo.users.login(user) + mo.users.reset_user_dsn(user) self.spend_request_and_commit() def spend_request_and_commit(self): -- GitLab From a19fe6618eb9d02c1586668b380ce876c745aa82 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 00:03:14 +0100 Subject: [PATCH 16/35] =?UTF-8?q?Registrace:=20Workflow=20pro=20ov=C4=9B?= =?UTF-8?q?=C5=99en=C3=AD=20e-mailu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/email.py | 11 +++ mo/web/acct.py | 93 ++++++++++++++++++- ...il.html => acct_confirm_email_change.html} | 0 .../acct_confirm_email_validity.html | 8 ++ mo/web/templates/acct_validate_email.html | 32 +++++++ 5 files changed, 140 insertions(+), 4 deletions(-) rename mo/web/templates/{acct_confirm_email.html => acct_confirm_email_change.html} (100%) create mode 100644 mo/web/templates/acct_confirm_email_validity.html create mode 100644 mo/web/templates/acct_validate_email.html diff --git a/mo/email.py b/mo/email.py index 7e816724..1b03ae09 100644 --- a/mo/email.py +++ b/mo/email.py @@ -264,6 +264,17 @@ def send_confirm_change_email(user: db.User, rr: db.RegRequest) -> bool: '''.format(confirm_url('e', rr.email_token))), override_email=rr.email, rr=rr) +def send_confirm_validate_email(user: db.User, rr: db.RegRequest) -> bool: + return send_user_email(user, 'Potvrzení e-mailové adresy', textwrap.dedent('''\ + Pro potvrzení e-mailové adresy k účtu v Odevzdávacím systému Matematické + olympiády následujte tento odkaz: + + {} + + Váš OSMO + '''.format(confirm_url('v', rr.email_token))), rr=rr) + + def send_join_notify_email(dest: db.User, who: db.User, contest: db.Contest) -> bool: round = contest.round place = contest.place diff --git a/mo/web/acct.py b/mo/web/acct.py index a6646d22..deefa8cc 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -452,6 +452,11 @@ class Reg2: RegStatus.expired: 'Vypršela platnost kódu pro obnovení hesla, požádejte prosím o obnovu znovu.', RegStatus.already_spent: 'Tento odkaz na obnovení hesla byl již využit.', }, + db.RegReqType.validate_email: { + RegStatus.new: 'Chybný potvrzovací kód. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.', + RegStatus.expired: 'Vypršela platnost potvrzovacího kódu, požádejte prosím o potvrzení e-mailu znovu.', + RegStatus.already_spent: 'Tento odkaz na potvrzení funkčnosti e-mailu byl již využit.', + }, } def __init__(self, token: Optional[str], expected_type: db.RegReqType): @@ -554,6 +559,12 @@ class Reg2: self.spend_request_and_commit() return True + def validate_email(self) -> None: + user = self.rr.user + app.logger.info(f'Reg2: Uživatel #{user.user_id} potvrzuje email <{user.email}>') + mo.users.reset_user_dsn(user) + self.spend_request_and_commit() + def change_passwd(self, new_passwd: str): sess = db.get_session() user = self.rr.user @@ -608,7 +619,7 @@ def confirm_reg(): return render_template('acct_reg2.html', form=form) -class ConfirmEmailForm(FlaskForm): +class ConfirmEmailChangeForm(FlaskForm): orig_email = wtforms.StringField('Původní e-mail', render_kw={"disabled": "disabled"}) new_email = wtforms.StringField('Nový e-mail', render_kw={"disabled": "disabled"}) submit = wtforms.SubmitField('Potvrdit změnu') @@ -617,13 +628,13 @@ class ConfirmEmailForm(FlaskForm): # URL je explicitně uvedeno v mo.email.confirm_url @app.route('/acct/confirm/e', methods=('GET', 'POST')) -def confirm_email(): +def confirm_email_change(): reg2 = Reg2(request.args.get('token'), db.RegReqType.change_email) if reg2.status != RegStatus.ok: reg2.flash_message() return redirect(url_for('user_settings')) - form = ConfirmEmailForm() + form = ConfirmEmailChangeForm() if form.validate_on_submit(): if form.submit.data: if reg2.change_email(): @@ -638,7 +649,38 @@ def confirm_email(): form.orig_email.data = reg2.rr.user.email form.new_email.data = reg2.rr.email - return render_template('acct_confirm_email.html', form=form) + return render_template('acct_confirm_email_change.html', form=form) + + +class ConfirmEmailValidityForm(FlaskForm): + email = wtforms.StringField('E-mail', render_kw={"disabled": "disabled"}) + submit = wtforms.SubmitField('Potvrdit') + cancel = wtforms.SubmitField('Zrušit požadavek') + + +# URL je explicitně uvedeno v mo.email.confirm_url +@app.route('/acct/confirm/v', methods=('GET', 'POST')) +def confirm_email_validity(): + reg2 = Reg2(request.args.get('token'), db.RegReqType.validate_email) + if reg2.status != RegStatus.ok: + reg2.flash_message() + return redirect(url_for('user_settings')) + + form = ConfirmEmailValidityForm() + if form.validate_on_submit(): + if form.submit.data: + reg2.validate_email() + flash('E-mail potvrzen.', 'success') + return redirect(url_for('index')) + elif form.cancel.data: + reg2.spend_request_and_commit() + flash('Požadavek na potvrzení e-mailu zrušen.', 'success') + return redirect(url_for('user_settings')) + + reg2.flash_message() + form.email.data = reg2.rr.user.email + + return render_template('acct_confirm_email_validity.html', form=form) class CancelResetForm(FlaskForm): @@ -687,6 +729,49 @@ def user_dsn(): return render_template('acct_dsn.html', user=user, dsns=dsns) +class ValidateEmailForm(FlaskForm): + submit = wtforms.SubmitField('Ověřit e-mail') + + +@app.route('/acct/settings/validate-email', methods=('GET', 'POST')) +@app.route('/acct/settings/validate-email/wait', endpoint='user_validate_email_wait') +def user_validate_email(): + sess = db.get_session() + user = g.user + if not user: + raise NeedLoginError() + + if mo.users.email_is_fake(user.email): + flash('Tento účet nemá platnou e-mailovou adresu.', 'danger') + return redirect(url_for('user_settings')) + + if user.dsn is None: + flash('Tento účet už má ověřenou e-mailovou adresu.', 'success') + return redirect(url_for('user_settings')) + + if request.endpoint != 'user_validate_email_wait': + form = ValidateEmailForm() + if form.validate_on_submit(): + ok = True + rr = mo.users.new_reg_request(db.RegReqType.validate_email, request.remote_addr) + if rr: + rr.user_id = user.user_id + sess.add(rr) + sess.commit() + app.logger.info(f'Validace e-mailu: Požadavek pro uživatele #{user.user_id}') + mo.email.send_confirm_validate_email(user, rr) + else: + app.logger.info('Validace e-mailu: Rate limit') + flash('Příliš mnoho požadavků na validaci e-mailu. Počkejte prosím chvíli a zkuste to znovu.', 'danger') + ok = False + if ok: + return redirect(url_for('user_validate_email_wait')) + else: + form = None + + return render_template('acct_validate_email.html', form=form) + + if getattr(config, 'INSECURE_TEST_LOGIN', False): @app.route('/test-login/<email>') def test_login(email: str): diff --git a/mo/web/templates/acct_confirm_email.html b/mo/web/templates/acct_confirm_email_change.html similarity index 100% rename from mo/web/templates/acct_confirm_email.html rename to mo/web/templates/acct_confirm_email_change.html diff --git a/mo/web/templates/acct_confirm_email_validity.html b/mo/web/templates/acct_confirm_email_validity.html new file mode 100644 index 00000000..3316e1b0 --- /dev/null +++ b/mo/web/templates/acct_confirm_email_validity.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Potvrzení platnosti e-mailu{% endblock %} +{% block body %} + +{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }} + +{% endblock %} diff --git a/mo/web/templates/acct_validate_email.html b/mo/web/templates/acct_validate_email.html new file mode 100644 index 00000000..a801de71 --- /dev/null +++ b/mo/web/templates/acct_validate_email.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Ověření e-mailové adresy{% endblock %} +{% block body %} + +{% if form %} + +<p> + Abychom ověřili platnosti vaši e-mailové adresy, pošleme na ni e-mail. + Až přijde, klikněte na odkaz v něm obsažený. +</p> + +{{ wtf.quick_form(form, form_type='simple', button_map={'submit': 'primary'}) }} + +{% else %} + +<p> + Vyčkejte prosím, až vám přijde e-mail. +</p> + +<p> + Pakliže nepřichází: +</p> + +<div class="btn-group"> + <a class='btn btn-default' href='{{ url_for('user_settings_personal') }}'>Změnit e-mail</a> + <a class='btn btn-default' href="{{ url_for('user_settings') }}">Zpět na nastavení účtu</a> +</div> + +{% endif %} + +{% endblock %} -- GitLab From a0f76898d062cb1733d956db06a38435283e7b6a Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 00:05:23 +0100 Subject: [PATCH 17/35] =?UTF-8?q?Kosmetick=C3=A1=20=C3=BAprava=20str=C3=A1?= =?UTF-8?q?nky=20na=20editaci=20=C3=BA=C4=8Dtu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/templates/org_user_edit.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mo/web/templates/org_user_edit.html b/mo/web/templates/org_user_edit.html index 529f49da..f2f7e5aa 100644 --- a/mo/web/templates/org_user_edit.html +++ b/mo/web/templates/org_user_edit.html @@ -16,7 +16,7 @@ <tr><td>Poznámka:</td><td style="white-space: pre-line;">{{ user.note }}</td></tr> </table> -<a href='{% if is_org %}{{ url_for('org_org', id=user.user_id) }}{% else %}{{ url_for('org_user', id=user.user_id) }}{% endif %}'>Zpět na detail</a> +<a class='btn btn-default' href='{{ user|user_url }}'>Zpět na detail</a> <hr> -- GitLab From 333dcf2b8da16a64c20712dccde9683f30605ef8 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 00:28:20 +0100 Subject: [PATCH 18/35] =?UTF-8?q?DSN:=20V=C3=BDklad=20b=C4=9B=C5=BEn=C3=BD?= =?UTF-8?q?ch=20status=20k=C3=B3d=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/util_dsn.py | 28 ++++++++++++++++++++++++++++ mo/web/jinja.py | 2 ++ mo/web/templates/parts/dsn.html | 2 +- 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 mo/util_dsn.py diff --git a/mo/util_dsn.py b/mo/util_dsn.py new file mode 100644 index 00000000..b442e256 --- /dev/null +++ b/mo/util_dsn.py @@ -0,0 +1,28 @@ +# Utility na práci s mailovými nedoručenkami + +from typing import Optional + + +dsn_explanation = { + '4.2.2': 'schránka je plná', + '4.4.1': 'server nepřijímá spojení', + '5.1.1': 'adresa neexistuje', + '5.1.10': 'doména nepřijímá poštu', # Null MX + '5.2.1': 'schránka je zablokována', + '5.2.2': 'schránka je plná', + '5.4.1': 'server nepřijímá spojení', + '5.4.6': 'cyklické přeposílání pošty', + '5.4.14': 'cyklické přeposílání pošty', # nestandardní status od MS Exchange + '5.7.1': 'příjem pošty zakázán pravidly cílové sítě', +} + + +def format_dsn_status(status: Optional[str]) -> str: + if not status: + return '–' + else: + explain = dsn_explanation.get(status) + if explain: + return f'{status} – {explain}' + else: + return status diff --git a/mo/web/jinja.py b/mo/web/jinja.py index 2a890c51..6df9522b 100644 --- a/mo/web/jinja.py +++ b/mo/web/jinja.py @@ -14,6 +14,7 @@ import mo.place_level import mo.points from mo.rights import Right from mo.util import assert_not_none +from mo.util_dsn import format_dsn_status import mo.util_format as util_format from mo.web import app from mo.web.org_place import place_breadcrumbs @@ -69,6 +70,7 @@ jg.update(Right=Right) jg.update(place_breadcrumbs=place_breadcrumbs) # Funkce asset_url se přidává v mo.ext.assets +jf.update(dsn_status=format_dsn_status) jf.update(user_url=user_url) diff --git a/mo/web/templates/parts/dsn.html b/mo/web/templates/parts/dsn.html index 5feed019..f7260c86 100644 --- a/mo/web/templates/parts/dsn.html +++ b/mo/web/templates/parts/dsn.html @@ -8,7 +8,7 @@ <td>{{ dsn.arrived_at|time_and_timedelta }} <tr> <th>Kód chyby - <td>{{ dsn.status }} + <td>{{ dsn.status|dsn_status }} <tr> <th>Chybová zpráva <td>{{ dsn.diag_code|or_dash }} -- GitLab From 8609c9216828500b06396d0454d26fde99caa5ba Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 00:31:56 +0100 Subject: [PATCH 19/35] =?UTF-8?q?PagerForm=20nepot=C5=99ebuje=20submit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Všichni jeho potomci si submit definují vlastní. --- mo/web/util.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mo/web/util.py b/mo/web/util.py index d7987db5..148665f1 100644 --- a/mo/web/util.py +++ b/mo/web/util.py @@ -21,7 +21,6 @@ class PagerForm(FlaskForm): offset = wtforms.IntegerField() next = wtforms.SubmitField("Další") previous = wtforms.SubmitField("Předchozí") - submit = wtforms.SubmitField("Filtrovat") def apply_limits(self, query: Query, pagesize: int = 50) -> Tuple[int, Query]: count = db.get_count(query) -- GitLab From bc4d3218a5a3ad8a19ec125df62b9bf9f534fde5 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 00:52:23 +0100 Subject: [PATCH 20/35] =?UTF-8?q?Nov=C3=A1=20sekce=20webu=20s=20n=C3=A1str?= =?UTF-8?q?oji=20pro=20spr=C3=A1vce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/__init__.py | 6 ++++++ mo/web/admin.py | 1 + 2 files changed, 7 insertions(+) create mode 100644 mo/web/admin.py diff --git a/mo/web/__init__.py b/mo/web/__init__.py index e8dbd227..743333c7 100644 --- a/mo/web/__init__.py +++ b/mo/web/__init__.py @@ -191,6 +191,11 @@ def init_request(): raise NeedLoginError() if user.is_org or user.is_admin: return redirect(url_for('org_index')) + elif path.startswith('/admin/'): + if not user: + raise NeedLoginError() + if not user.is_admin: + raise werkzeug.exceptions.Forbidden() elif path.startswith('/doc/'): if user and (user.is_org or user.is_admin): g.gatekeeper = mo.rights.Gatekeeper(user) @@ -260,6 +265,7 @@ except ImportError: # Většina webu je v samostatných modulech +import mo.web.admin import mo.web.api import mo.web.api_dsn import mo.web.acct diff --git a/mo/web/admin.py b/mo/web/admin.py new file mode 100644 index 00000000..93e759f7 --- /dev/null +++ b/mo/web/admin.py @@ -0,0 +1 @@ +# Web: Nástroje pro správce -- GitLab From cd5f51530504b2006c6d0184b2aa94ea6475f633 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 00:53:26 +0100 Subject: [PATCH 21/35] =?UTF-8?q?PagerForm=20um=C3=AD=20po=C4=8D=C3=ADtat?= =?UTF-8?q?=20prvn=C3=AD=20a=20posledn=C3=AD=20polo=C5=BEku?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/templates/org_generic_list.html | 3 +-- mo/web/templates/org_orgs.html | 3 +-- mo/web/templates/org_users.html | 3 +-- mo/web/util.py | 14 ++++++++++++++ 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/mo/web/templates/org_generic_list.html b/mo/web/templates/org_generic_list.html index 106a0342..e6c2e900 100644 --- a/mo/web/templates/org_generic_list.html +++ b/mo/web/templates/org_generic_list.html @@ -60,9 +60,8 @@ <input type="hidden" name="offset" value="{{filter.offset.data}}"> <input type="hidden" name="limit" value="{{filter.limit.data}}"> </div> - {% set max = filter.offset.data + filter.limit.data if filter.offset.data + filter.limit.data < count else count %} {% if count > 0 %} - Zobrazuji záznamy <b>{{filter.offset.data + 1}}</b> až <b>{{ max }}</b> z <b>{{count}} nalezených účastníků</b>. + Zobrazuji záznamy <b>{{filter.get_min()}}</b> až <b>{{filter.get_max(count)}}</b> z <b>{{count}} nalezených účastníků</b>. {% else %} <b>Nebyly nalezeny žádné záznamy účastníků.</b> {% endif %} diff --git a/mo/web/templates/org_orgs.html b/mo/web/templates/org_orgs.html index 6bbe924a..5ee560b4 100644 --- a/mo/web/templates/org_orgs.html +++ b/mo/web/templates/org_orgs.html @@ -61,9 +61,8 @@ <button class="btn" disabled>Další</button> {% endif %} </div> - {% set max = filter.offset.data + filter.limit.data if filter.offset.data + filter.limit.data < count else count %} {% if count > 0 %} - Zobrazuji záznamy <b>{{filter.offset.data + 1}}</b> až <b>{{ max }}</b> z <b>{{count}} nalezených organizátorů</b>. + Zobrazuji záznamy <b>{{filter.get_min()}}</b> až <b>{{filter.get_max(count)}}</b> z <b>{{count}} nalezených organizátorů</b>. {% else %} <b>Nebyly nalezeny žádné záznamy organizátorů.</b> {% endif %} diff --git a/mo/web/templates/org_users.html b/mo/web/templates/org_users.html index 82aaf3a7..198e5b65 100644 --- a/mo/web/templates/org_users.html +++ b/mo/web/templates/org_users.html @@ -54,9 +54,8 @@ <button class="btn" disabled>Další</button> {% endif %} </div> - {% set max = filter.offset.data + filter.limit.data if filter.offset.data + filter.limit.data < count else count %} {% if count > 0 %} - Zobrazuji záznamy <b>{{filter.offset.data + 1}}</b> až <b>{{ max }}</b> z <b>{{count}} nalezených soutěžících</b>. + Zobrazuji záznamy <b>{{filter.get_min()}}</b> až <b>{{filter.get_max(count)}}</b> z <b>{{count}} nalezených soutěžících</b>. {% else %} <b>Nebyly nalezeny žádné záznamy soutěžících.</b> {% endif %} diff --git a/mo/web/util.py b/mo/web/util.py index 148665f1..245438eb 100644 --- a/mo/web/util.py +++ b/mo/web/util.py @@ -40,6 +40,20 @@ class PagerForm(FlaskForm): return (count, query) + def get_min(self) -> int: + if self.offset.data is not None: + return self.offset.data + 1 + else: + return 0 + + def get_max(self, count) -> int: + if self.offset.data is None or self.limit.data is None: + return 0 + if self.offset.data + self.limit.data < count: + return self.offset.data + self.limit.data + else: + return count + def task_statement_exists(round: db.Round) -> bool: if round.tasks_file is None: -- GitLab From 2854c4728c7899b409d39cac330226f78419a860 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 00:54:13 +0100 Subject: [PATCH 22/35] =?UTF-8?q?DSN:=20P=C5=99ehled=20v=C5=A1ech=20DSN=20?= =?UTF-8?q?pro=20spr=C3=A1vce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/admin.py | 20 ++++++++++++++++++++ mo/web/templates/admin_dsn.html | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 mo/web/templates/admin_dsn.html diff --git a/mo/web/admin.py b/mo/web/admin.py index 93e759f7..71a9e83a 100644 --- a/mo/web/admin.py +++ b/mo/web/admin.py @@ -1 +1,21 @@ # Web: Nástroje pro správce + +from flask import render_template, request + +import mo.db as db +from mo.web import app +from mo.web.util import PagerForm + + +@app.route('/admin/dsn') +def admin_all_dsn(): + pager = PagerForm(formdata=request.args) + if request.args: + pager.validate() + + sess = db.get_session() + query = sess.query(db.EmailDSN).order_by(db.EmailDSN.arrived_at.desc()) + (count, query) = pager.apply_limits(query, pagesize=20) + dsns = query.all() + + return render_template('admin_dsn.html', dsns=dsns, pager=pager, count=count) diff --git a/mo/web/templates/admin_dsn.html b/mo/web/templates/admin_dsn.html new file mode 100644 index 00000000..2a08bef6 --- /dev/null +++ b/mo/web/templates/admin_dsn.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Všechny nedoručenky{% endblock %} +{% block body %} + +<div class="btn-group"> + {% if pager.offset.data > 0 %} + {{ wtf.form_field(pager.previous) }} + {% else %} + <button class="btn" disabled>Předchozí</button> + {% endif %} + {% if count > pager.offset.data + pager.limit.data %} + {{ wtf.form_field(pager.next) }} + {% else %} + <button class="btn" disabled>Další</button> + {% endif %} +</div> + +{% if count > 0 %} + Zobrazuji nedoručenky <b>{{pager.get_min()}}</b> až <b>{{pager.get_max(count)}}</b> z <b>{{count}} nalezených</b>. +{% else %} + <b>Nebyly nalezeny žádné nedoručenky.</b> +{% endif %} + +{% include "parts/dsn.html" %} + +{% endblock %} -- GitLab From 111787ed8c6f61a4a3f684de55bb417ff5ff8b61 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 00:58:21 +0100 Subject: [PATCH 23/35] =?UTF-8?q?Admin:=20Odkaz=20na=20seznam=20DSN=20z=20?= =?UTF-8?q?hlavn=C3=AD=20str=C3=A1nky?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/templates/org_index.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mo/web/templates/org_index.html b/mo/web/templates/org_index.html index a0229652..aa72dcd3 100644 --- a/mo/web/templates/org_index.html +++ b/mo/web/templates/org_index.html @@ -84,13 +84,17 @@ {% if g.user.is_admin %} -<h3>Univerzální hledátko</h3> +<h3>Nástroje pro správce</h3> <form method=GET action="" class='form form-inline' role=form> <input class='form-control' name=search size=32 placeholder='cID pID rID uID x@y.z' autofocus></input> <input class='btn btn-primary' type="submit" value='Vyhledat'> </form> +<div class='button-group space-top'> + <a class='btn btn-default' href='{{ url_for('admin_all_dsn') }}'>Nedoručenky</a> +</div> + {% endif %} {% endblock %} -- GitLab From 03abff5ad80571f1c69a0606c6c7e083a2218fc5 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 01:20:55 +0100 Subject: [PATCH 24/35] =?UTF-8?q?DSN:=20Expirace=20nedoru=C4=8Denek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nemažeme nedoručenky, na které se odkazuje nějaký účet. Ale o registraci se nestaráme, ta velmi pravděpodobně expiruje dřív. --- etc/config.py.example | 3 +++ mo/util_dsn.py | 22 ++++++++++++++++++++++ mo/web/__init__.py | 3 +++ 3 files changed, 28 insertions(+) diff --git a/etc/config.py.example b/etc/config.py.example index 9c02e622..15d62304 100644 --- a/etc/config.py.example +++ b/etc/config.py.example @@ -80,6 +80,9 @@ REG_MAX_PER_MINUTE = 10 # Jak dlouho vydrží tokeny používané při registraci a změnách e-mailu [min] REG_TOKEN_VALIDITY = 10 +# Jak dlouho si pamatujeme e-mailovou nedoručenku [min] +DSN_EXPIRATION = 30 * 1440 + # Aktuální ročník MO CURRENT_YEAR = 71 diff --git a/mo/util_dsn.py b/mo/util_dsn.py index b442e256..64fe8cd7 100644 --- a/mo/util_dsn.py +++ b/mo/util_dsn.py @@ -1,7 +1,13 @@ # Utility na práci s mailovými nedoručenkami +from datetime import timedelta +from sqlalchemy import select from typing import Optional +import mo +import mo.config as config +import mo.db as db + dsn_explanation = { '4.2.2': 'schránka je plná', @@ -26,3 +32,19 @@ def format_dsn_status(status: Optional[str]) -> str: return f'{status} – {explain}' else: return status + + +def expire_dsns() -> None: + expiration = getattr(config, 'DSN_EXPIRATION', None) + if expiration is None: + return + + sess = db.get_session() + conn = sess.connection() + user_table = db.User.__table__ + dsn_table = db.EmailDSN.__table__ + conn.execute(dsn_table.delete() + .where(dsn_table.c.arrived_at < mo.now - timedelta(days=expiration)) + .where(dsn_table.c.dsn_id.not_in(select(user_table.c.dsn_id)))) + + sess.commit() diff --git a/mo/web/__init__.py b/mo/web/__init__.py index 743333c7..f8ccc040 100644 --- a/mo/web/__init__.py +++ b/mo/web/__init__.py @@ -23,6 +23,7 @@ import mo.jobs.submit import mo.rights import mo.users import mo.util +import mo.util_dsn class MOFlask(Flask): @@ -210,11 +211,13 @@ app.before_request(init_request) # - projdeme joby pro případ, že by se ztratil signál # - expirujeme zastaralé joby # - expirujeme zastaralé registrační tokeny +# - expirujeme zastaralé nedoručenky def collect_garbage() -> None: mo.now = mo.util.get_now() mo.jobs.submit.check_broken_submits() mo.jobs.process_jobs() mo.users.expire_reg_requests() + mo.util_dsn.expire_dsns() @app.cli.command('gc') -- GitLab From 48145ba153c36037b5e53ad7c39a51cd08c60303 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 01:24:39 +0100 Subject: [PATCH 25/35] =?UTF-8?q?DSN:=20reset=5Fuser=5Fdsn()=20p=C5=99esou?= =?UTF-8?q?v=C3=A1me=20do=20mo.util=5Fdsn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/users.py | 9 --------- mo/util_dsn.py | 10 ++++++++++ mo/web/acct.py | 6 +++--- mo/web/org_users.py | 3 ++- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/mo/users.py b/mo/users.py index 0754725c..ef89b1ae 100644 --- a/mo/users.py +++ b/mo/users.py @@ -467,12 +467,3 @@ def request_reset_password(user: db.User, client: Optional[str]) -> Optional[db. details={'action': 'ask-reset'}, ) return rr - - -def reset_user_dsn(user: db.User) -> None: - if user.dsn_id: - logger.info(f'Uživatel #{user.user_id}: Reset DSN') - user.dsn_id = None - conn = db.get_session().connection() - dsn_table = db.EmailDSN.__table__ - conn.execute(dsn_table.delete().where(dsn_table.c.user_id == user.user_id)) diff --git a/mo/util_dsn.py b/mo/util_dsn.py index 64fe8cd7..165530f0 100644 --- a/mo/util_dsn.py +++ b/mo/util_dsn.py @@ -7,6 +7,7 @@ from typing import Optional import mo import mo.config as config import mo.db as db +from mo.util import logger dsn_explanation = { @@ -48,3 +49,12 @@ def expire_dsns() -> None: .where(dsn_table.c.dsn_id.not_in(select(user_table.c.dsn_id)))) sess.commit() + + +def reset_user_dsn(user: db.User) -> None: + if user.dsn_id: + logger.info(f'Uživatel #{user.user_id}: Reset DSN') + user.dsn_id = None + conn = db.get_session().connection() + dsn_table = db.EmailDSN.__table__ + conn.execute(dsn_table.delete().where(dsn_table.c.user_id == user.user_id)) diff --git a/mo/web/acct.py b/mo/web/acct.py index deefa8cc..a9072301 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -555,14 +555,14 @@ class Reg2: }, ) - mo.users.reset_user_dsn(user) + mo.util_dsn.reset_user_dsn(user) self.spend_request_and_commit() return True def validate_email(self) -> None: user = self.rr.user app.logger.info(f'Reg2: Uživatel #{user.user_id} potvrzuje email <{user.email}>') - mo.users.reset_user_dsn(user) + mo.util_dsn.reset_user_dsn(user) self.spend_request_and_commit() def change_passwd(self, new_passwd: str): @@ -572,7 +572,7 @@ class Reg2: app.logger.info(f'Reg2: Uživatel #{user.user_id} si resetoval heslo') mo.users.set_password(user, new_passwd, reset=True) mo.users.login(user) - mo.users.reset_user_dsn(user) + mo.util_dsn.reset_user_dsn(user) self.spend_request_and_commit() def spend_request_and_commit(self): diff --git a/mo/web/org_users.py b/mo/web/org_users.py index 43f104b0..be60c783 100644 --- a/mo/web/org_users.py +++ b/mo/web/org_users.py @@ -21,6 +21,7 @@ from mo.imports import GlobalOrgsImport import mo.rights from mo.rights import Right, Rights import mo.util +import mo.util_dsn import mo.users from mo.web import app import mo.web.fields as mo_fields @@ -525,7 +526,7 @@ def org_user_edit(id: int): if sess.is_modified(user): changes = db.get_object_changes(user) if 'email' in changes: - mo.users.reset_user_dsn(user) + mo.util_dsn.reset_user_dsn(user) app.logger.info(f"Uživatel #{id} změněn: {changes}") mo.util.log( type=db.LogType.user, -- GitLab From 636af5efb22d6c16745f9f146d5a3ef1941422a5 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 01:29:55 +0100 Subject: [PATCH 26/35] =?UTF-8?q?DSN:=20Adminsk=C3=BD=20p=C5=99ehled=20uka?= =?UTF-8?q?zuje,=20koho=20se=20nedoru=C4=8Denka=20t=C3=BDk=C3=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/admin.py | 7 +++++-- mo/web/templates/parts/dsn.html | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/mo/web/admin.py b/mo/web/admin.py index 71a9e83a..c31dc9d0 100644 --- a/mo/web/admin.py +++ b/mo/web/admin.py @@ -1,6 +1,7 @@ # Web: Nástroje pro správce from flask import render_template, request +from sqlalchemy.orm import joinedload import mo.db as db from mo.web import app @@ -14,8 +15,10 @@ def admin_all_dsn(): pager.validate() sess = db.get_session() - query = sess.query(db.EmailDSN).order_by(db.EmailDSN.arrived_at.desc()) + query = (sess.query(db.EmailDSN) + .options(joinedload(db.EmailDSN.user), joinedload(db.EmailDSN.reg)) + .order_by(db.EmailDSN.arrived_at.desc())) (count, query) = pager.apply_limits(query, pagesize=20) dsns = query.all() - return render_template('admin_dsn.html', dsns=dsns, pager=pager, count=count) + return render_template('admin_dsn.html', dsns=dsns, pager=pager, count=count, admin_list=True) diff --git a/mo/web/templates/parts/dsn.html b/mo/web/templates/parts/dsn.html index f7260c86..06d0da26 100644 --- a/mo/web/templates/parts/dsn.html +++ b/mo/web/templates/parts/dsn.html @@ -6,6 +6,18 @@ <tr> <th>Čas <td>{{ dsn.arrived_at|time_and_timedelta }} + {% if admin_list %} + <tr> + <th>Pro koho + {% if dsn.user is not none %} + <td>Uživatel {{ dsn.user|user_link }}{{ dsn.user|user_flags }} + {% if dsn.user.dsn == dsn %} (primární DSN){% endif %} + {% elif dsn.reg_id is not none %} + <td>Registrace #{{dsn.reg_id}} ({{dsn.reg.type.name}}) + {% else %} + <td>??? + {% endif %} + {% endif %} <tr> <th>Kód chyby <td>{{ dsn.status|dsn_status }} -- GitLab From 7cfa1b874462b6dc87205ee490164826d572242c Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 11:24:05 +0100 Subject: [PATCH 27/35] =?UTF-8?q?DSN:=20Na=20=C3=BA=C4=8Dty=20s=20nedosa?= =?UTF-8?q?=C5=BEitelnou=20adresou=20nepos=C3=ADl=C3=A1me=20notifikace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/db.py | 4 ++++ mo/jobs/notify.py | 2 +- mo/web/user.py | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/mo/db.py b/mo/db.py index 185da8e1..6be1ff0d 100644 --- a/mo/db.py +++ b/mo/db.py @@ -467,6 +467,10 @@ class User(Base): def is_system(self) -> bool: return self.user_id == 0 + @property + def wants_notify(self) -> bool: + return self.email_notify and self.dsn_id is None + def get_system_user() -> User: """Uživatel s user_id=0 je systémový (viz db.ddl)""" diff --git a/mo/jobs/notify.py b/mo/jobs/notify.py index ea522b7d..50097dea 100644 --- a/mo/jobs/notify.py +++ b/mo/jobs/notify.py @@ -70,7 +70,7 @@ def handle_send_grading_info(the_job: TheJob): for user, sent in todo: num_total += 1 - if not user.email_notify: + if not user.wants_notify: num_dont_want += 1 elif sent: num_before += 1 diff --git a/mo/web/user.py b/mo/web/user.py index 17364b2b..d39a61df 100644 --- a/mo/web/user.py +++ b/mo/web/user.py @@ -266,8 +266,8 @@ def join_notify(c: db.Contest) -> None: notify = {ur.user for ur in uroles if ur.applies_to(at=place, year=r.year, cat=r.category, seq=r.seq)} if notify: for org in notify: - logger.info(f'Join: {"Notifikuji" if org.email_notify else "Nenotifikuji"} orga <{org.email}> pro místo {place.get_code()}') - if org.email_notify: + logger.info(f'Join: {"Notifikuji" if org.wants_notify else "Nenotifikuji"} orga <{org.email}> pro místo {place.get_code()}') + if org.wants_notify: mo.email.send_join_notify_email(org, g.user, c) return place = place.parent_place -- GitLab From 2f76c127cd348f2367f4e763a36ea215d120f65c Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 11:26:05 +0100 Subject: [PATCH 28/35] =?UTF-8?q?V=C3=BDpis=20rol=C3=AD=20k=20m=C3=ADstu?= =?UTF-8?q?=20ukazuje=20user=5Fflags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/templates/org_place_roles.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mo/web/templates/org_place_roles.html b/mo/web/templates/org_place_roles.html index dfaaa739..7f9fa673 100644 --- a/mo/web/templates/org_place_roles.html +++ b/mo/web/templates/org_place_roles.html @@ -23,7 +23,7 @@ {% for role in roles %} <tr> <td>{{ roles_by_type[role.role].name }} - <td>{{ role.user|user_link }} + <td>{{ role.user|user_link }}{{ role.user|user_flags }} <td>{{ role.year or '–' }} <td>{{ role.category or '–' }} <td>{{ role.seq or '–' }} -- GitLab From 9c4d16d4fa693af66580b5b3ec3bd7cde933a7c4 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 11:35:57 +0100 Subject: [PATCH 29/35] =?UTF-8?q?Zm=C4=9Bna=20osobn=C3=ADch=20=C3=BAdaj?= =?UTF-8?q?=C5=AF=20zapom=C3=ADnala=20kontrolovat=20aktu=C3=A1ln=C3=AD=20h?= =?UTF-8?q?eslo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *blush* --- mo/web/acct.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/mo/web/acct.py b/mo/web/acct.py index a9072301..b15a62be 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -23,6 +23,7 @@ import mo.rights import mo.tokens import mo.users import mo.util +from mo.util import assert_not_none from mo.web import app, NeedLoginError import mo.web.fields as mo_fields @@ -183,8 +184,11 @@ def user_settings_personal(): if not form.submit.data: form.email.data = user.email - if form.validate_on_submit(): - ok = True + def process_submit() -> bool: + if not mo.users.check_password(user, assert_not_none(form.current_passwd.data)): + flash('Nesouhlasí aktuální heslo.', 'danger') + return False + if form.new_passwd.data: app.logger.info(f'Settings: Změněno heslo uživatele #{user.user_id}') mo.users.set_password(user, form.new_passwd.data) @@ -195,10 +199,12 @@ def user_settings_personal(): ) sess.commit() flash('Heslo změněno.', 'success') + if form.email.data != user.email: - if mo.users.user_by_email(form.email.data) is not None: + if mo.users.user_by_email(assert_not_none(form.email.data)) is not None: + # Tady prosakuje informace o existenci účtu, ale tu prozrazuje i login. flash('Tuto e-mailovou adresu už používá jiný uživatel.', 'danger') - ok = False + return False else: rr = mo.users.new_reg_request(db.RegReqType.change_email, request.remote_addr) if rr: @@ -212,9 +218,11 @@ def user_settings_personal(): else: app.logger.info('Settings: Rate limit') flash('Příliš mnoho požadavků na změny e-mailu. Počkejte prosím chvíli a zkuste to znovu.', 'danger') - ok = False - if ok: - return redirect(url_for('user_settings')) + return False + return True + + if form.validate_on_submit() and process_submit(): + return redirect(url_for('user_settings')) return render_template('settings_change.html', form=form) -- GitLab From 565a0cb4a19cbe48c5a7b2cf924f8f3ae8eb0371 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 17:11:56 +0100 Subject: [PATCH 30/35] =?UTF-8?q?DSN:=20send=5Fpassword=5Freset=5Femail=20?= =?UTF-8?q?dost=C3=A1v=C3=A1=20cel=C3=BD=20RegRequest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ... a jeho DSN token je také vázaný na RegRequest, nejen na uživatele. --- mo/email.py | 4 ++-- mo/web/acct.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mo/email.py b/mo/email.py index 1b03ae09..78684545 100644 --- a/mo/email.py +++ b/mo/email.py @@ -225,7 +225,7 @@ def send_new_account_email(user: db.User, token: str) -> bool: '''.format(activate_url(token)))) -def send_password_reset_email(user: db.User, token: str) -> bool: +def send_password_reset_email(user: db.User, rr: db.RegRequest) -> bool: return send_user_email(user, 'Obnova hesla', textwrap.dedent('''\ Někdo požádal o obnovení hesla k Vašemu účtu v Odevzdávacím systému Matematické olympiády. @@ -235,7 +235,7 @@ def send_password_reset_email(user: db.User, token: str) -> bool: {} Váš OSMO - '''.format(confirm_url('p', token)))) + '''.format(confirm_url('p', rr.email_token))), rr=rr) def send_confirm_create_email(user: db.User, rr: db.RegRequest) -> bool: diff --git a/mo/web/acct.py b/mo/web/acct.py index b15a62be..e4b8d5f0 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -73,7 +73,7 @@ def login(): rr = mo.users.request_reset_password(user, request.remote_addr) if rr: db.get_session().commit() - mo.email.send_password_reset_email(user, rr.email_token) + mo.email.send_password_reset_email(user, rr) flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla.', 'success') else: flash('Příliš časté požadavky na obnovu hesla.', 'danger') -- GitLab From 11a89b4c4cb1689258f0888df7f5e228f4cbe38d Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 17:17:18 +0100 Subject: [PATCH 31/35] =?UTF-8?q?DSN:=20Obecn=C3=A9=20UI=20pro=20=C4=8Dek?= =?UTF-8?q?=C3=A1n=C3=AD=20na=20mail=20s=20periodickou=20kontrolou=20nedor?= =?UTF-8?q?u=C4=8Denek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/acct.py | 24 ++++++++++ mo/web/templates/acct_dsn_check.html | 22 +++++++++ mo/web/templates/parts/wait_for_email.html | 56 ++++++++++++++++++++++ static/mo.css | 15 ++++++ 4 files changed, 117 insertions(+) create mode 100644 mo/web/templates/acct_dsn_check.html create mode 100644 mo/web/templates/parts/wait_for_email.html diff --git a/mo/web/acct.py b/mo/web/acct.py index e4b8d5f0..1051ecc7 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -780,6 +780,30 @@ def user_validate_email(): return render_template('acct_validate_email.html', form=form) +def make_dsn_check_token(rr: db.RegRequest) -> str: + return mo.tokens.sign_token([str(rr.reg_id)], 'dsn-check') + + +@app.route('/acct/dsn-check/<token>') +def dsn_check(token): + # Jelikož nevyžadujeme login, nechceme předávat v URL přímo IDčka do databáze. + # Místo toho předáváme podepsaný token obsahující IDčko. + fields = mo.tokens.verify_token(token, 'dsn-check') + if not fields: + raise werkzeug.exceptions.NotFound() + + reg_id = int(fields[0]) + sess = db.get_session() + reg = sess.query(db.RegRequest).options(joinedload(db.RegRequest.dsn)).get(reg_id) + if not reg: + raise werkzeug.exceptions.NotFound() + + if reg.dsn is not None: + return render_template('acct_dsn_check.html', dsn=reg.dsn) + else: + return "" + + if getattr(config, 'INSECURE_TEST_LOGIN', False): @app.route('/test-login/<email>') def test_login(email: str): diff --git a/mo/web/templates/acct_dsn_check.html b/mo/web/templates/acct_dsn_check.html new file mode 100644 index 00000000..5b14c354 --- /dev/null +++ b/mo/web/templates/acct_dsn_check.html @@ -0,0 +1,22 @@ +<div class='alert alert-danger'> +E-mail se nepodařilo doručit. + +<table class='dsn-check-err'> + <tr> + <th>Kód chyby: + <td>{{ dsn.status|dsn_status }} + <tr> + <th>Chybová zpráva: + <td>{{ dsn.diag_code|or_dash }} + <tr> + <th>Vzdálený server: + <td>{{ dsn.remote_mta|or_dash }} +</table> +</div> + +{# + +{% set dsns = [dsn] %} +{% include "parts/dsn.html" %} + +#} diff --git a/mo/web/templates/parts/wait_for_email.html b/mo/web/templates/parts/wait_for_email.html new file mode 100644 index 00000000..e281d298 --- /dev/null +++ b/mo/web/templates/parts/wait_for_email.html @@ -0,0 +1,56 @@ +<div id='dsn-report'> +<p> + Počkejte prosím, až vám přijde e-mail, a klikněte na odkaz v něm uvedený. +</p> +</div> + +{% if dsn_check_token %} + +<script type="text/javascript"> +let dsn_check_counter = 0; +let dsn_stop = false; + +function schedule_dsn_check() +{ + if (dsn_stop) + return; + + dsn_check_counter++; + if (dsn_check_counter > 100) + return; + + let timeout = 30_000; + if (dsn_check_counter < 10) + timeout = 5_000; + + window.setTimeout(function() { + console.log('DSN: Checking'); + fetch("{{ url_for('dsn_check', token=dsn_check_token) }}") + .then(response => { + if (response.status >= 400 && response.status <= 499) + dsn_stop = true; + if (response.ok) + return response.text(); + else + throw Error("Failed with status code " + response.status) + }) + .then((value) => { + if (value != "") { + console.log('DSN: Received report') + const report_div = document.getElementById("dsn-report"); + report_div.innerHTML = value; + dsn_stop = true; + } + schedule_dsn_check(); + }) + .catch(err => { + console.log('DSN: ' + err) + schedule_dsn_check(); + }); + }, timeout); +} + +schedule_dsn_check(); +</script> + +{% endif %} diff --git a/static/mo.css b/static/mo.css index 58864bb1..655fbfec 100644 --- a/static/mo.css +++ b/static/mo.css @@ -596,6 +596,21 @@ table.data input.form-control { margin: 2ex 2ex; } +/* Upozornění na nedoručený mail v registraci */ + +table.dsn-check-err { + border-collapse: collapse; + margin-top: 2ex; +} + +table.dsn-check-err tr td, table.dsn-check-err tr th { + padding-top: 0.1ex; +} + +table.dsn-check-err tr th { + padding-right: 2ex; +} + /* Vzhled pro mobily a úzké displeje */ @media only screen and (max-width: 600px) { -- GitLab From 85343b78a3a54f0338f24400298e910c09d35d5f Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 17:18:44 +0100 Subject: [PATCH 32/35] =?UTF-8?q?Registrace:=20V=C5=A1echna=20workflows=20?= =?UTF-8?q?s=20=C4=8Dek=C3=A1n=C3=ADm=20na=20mail=20sjednocena?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/web/acct.py | 47 +++++++++++++------- mo/web/templates/acct_reg2.html | 2 +- mo/web/templates/acct_reset_passwd_wait.html | 7 +++ mo/web/templates/acct_validate_email.html | 12 ++--- mo/web/templates/settings_change.html | 12 +++++ 5 files changed, 53 insertions(+), 27 deletions(-) create mode 100644 mo/web/templates/acct_reset_passwd_wait.html diff --git a/mo/web/acct.py b/mo/web/acct.py index 1051ecc7..c76eff4b 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -60,7 +60,7 @@ def login(): if not form.validate_on_submit(): return render_template('login.html', form=form, error=None) - email = form.email.data + email = assert_not_none(form.email.data) user = mo.users.user_by_email(email) if not user: @@ -75,6 +75,7 @@ def login(): db.get_session().commit() mo.email.send_password_reset_email(user, rr) flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla.', 'success') + return redirect(url_for('reset_passwd_wait', dsn_check_token=make_dsn_check_token(rr))) else: flash('Příliš časté požadavky na obnovu hesla.', 'danger') elif not form.passwd.data or not mo.users.check_password(user, form.passwd.data): @@ -97,6 +98,11 @@ def login(): return render_template('login.html', form=form) +@app.route('/acct/reset-passwd/<dsn_check_token>') +def reset_passwd_wait(dsn_check_token: str): + return render_template('acct_reset_passwd_wait.html', dsn_check_token=dsn_check_token) + + @app.route('/acct/logout', methods=('POST',)) def logout(): session.clear() @@ -174,20 +180,24 @@ class PersonalSettingsForm(FlaskForm): @app.route('/acct/settings/personal', methods=('GET', 'POST')) -def user_settings_personal(): +@app.route('/acct/settings/personal/<dsn_check_token>', methods=('GET', 'POST')) +def user_settings_personal(dsn_check_token: Optional[str] = None): sess = db.get_session() user = g.user if not user: raise NeedLoginError() + if dsn_check_token: + return render_template('settings_change.html', form=None, dsn_check_token=dsn_check_token) + form = PersonalSettingsForm() if not form.submit.data: form.email.data = user.email - def process_submit() -> bool: + def process_submit() -> Optional[str]: if not mo.users.check_password(user, assert_not_none(form.current_passwd.data)): flash('Nesouhlasí aktuální heslo.', 'danger') - return False + return None if form.new_passwd.data: app.logger.info(f'Settings: Změněno heslo uživatele #{user.user_id}') @@ -204,7 +214,7 @@ def user_settings_personal(): if mo.users.user_by_email(assert_not_none(form.email.data)) is not None: # Tady prosakuje informace o existenci účtu, ale tu prozrazuje i login. flash('Tuto e-mailovou adresu už používá jiný uživatel.', 'danger') - return False + return None else: rr = mo.users.new_reg_request(db.RegReqType.change_email, request.remote_addr) if rr: @@ -215,14 +225,15 @@ def user_settings_personal(): app.logger.info(f'Settings: Požadavek na změnu e-mailu uživatele #{user.user_id}') flash('Odeslán e-mail s odkazem na potvrzení nové adresy.', 'success') mo.email.send_confirm_change_email(user, rr) + return url_for('user_settings_personal', dsn_check_token=make_dsn_check_token(rr)) else: app.logger.info('Settings: Rate limit') flash('Příliš mnoho požadavků na změny e-mailu. Počkejte prosím chvíli a zkuste to znovu.', 'danger') - return False - return True + return None + return url_for('user_settings') - if form.validate_on_submit() and process_submit(): - return redirect(url_for('user_settings')) + if form.validate_on_submit() and (redir_to := process_submit()): + return redirect(redir_to) return render_template('settings_change.html', form=form) @@ -417,7 +428,7 @@ def create_acct(): flash('Odeslán e-mail s odkazem na založení účtu.', 'success') user = db.User(email=form.email.data, first_name='Nový', last_name='Uživatel') mo.email.send_confirm_create_email(user, reg1.rr) - return redirect(url_for('confirm_reg')) + return redirect(url_for('confirm_reg', dsn_check_token=make_dsn_check_token(rr=reg1.rr))) form.captcha.description = reg1.captcha_task() if reg1.status != RegStatus.ok: @@ -604,10 +615,11 @@ class Reg2Form(FlaskForm): # URL je explicitně uvedeno v mo.email.confirm_url @app.route('/acct/confirm/r', methods=('GET', 'POST')) -def confirm_reg(): +@app.route('/acct/confirm/r/<dsn_check_token>', methods=('GET', 'POST')) +def confirm_reg(dsn_check_token: Optional[str] = None): token = request.args.get('token') if token is None: - return render_template('acct_reg2.html', form=None) + return render_template('acct_reg2.html', form=None, dsn_check_token=dsn_check_token) reg2 = Reg2(token, db.RegReqType.register) if reg2.status != RegStatus.ok: @@ -742,8 +754,8 @@ class ValidateEmailForm(FlaskForm): @app.route('/acct/settings/validate-email', methods=('GET', 'POST')) -@app.route('/acct/settings/validate-email/wait', endpoint='user_validate_email_wait') -def user_validate_email(): +@app.route('/acct/settings/validate-email/<dsn_check_token>') +def user_validate_email(dsn_check_token: Optional[str] = None): sess = db.get_session() user = g.user if not user: @@ -757,7 +769,7 @@ def user_validate_email(): flash('Tento účet už má ověřenou e-mailovou adresu.', 'success') return redirect(url_for('user_settings')) - if request.endpoint != 'user_validate_email_wait': + if not dsn_check_token: form = ValidateEmailForm() if form.validate_on_submit(): ok = True @@ -768,16 +780,17 @@ def user_validate_email(): sess.commit() app.logger.info(f'Validace e-mailu: Požadavek pro uživatele #{user.user_id}') mo.email.send_confirm_validate_email(user, rr) + dsn_check_token = make_dsn_check_token(rr=rr) else: app.logger.info('Validace e-mailu: Rate limit') flash('Příliš mnoho požadavků na validaci e-mailu. Počkejte prosím chvíli a zkuste to znovu.', 'danger') ok = False if ok: - return redirect(url_for('user_validate_email_wait')) + return redirect(url_for('user_validate_email', dsn_check_token=dsn_check_token, _method='GET')) else: form = None - return render_template('acct_validate_email.html', form=form) + return render_template('acct_validate_email.html', form=form, dsn_check_token=dsn_check_token) def make_dsn_check_token(rr: db.RegRequest) -> str: diff --git a/mo/web/templates/acct_reg2.html b/mo/web/templates/acct_reg2.html index a0f6eeaa..3b7ddc0b 100644 --- a/mo/web/templates/acct_reg2.html +++ b/mo/web/templates/acct_reg2.html @@ -18,7 +18,7 @@ kategorie a napište svému nadřazenému garantovi, ať vám přidělí práva. {% else %} -<p>Počkejte prosím, až vám přijde e-mail a klikněte na odkaz v něm uvedený. +{% include "parts/wait_for_email.html" %} {% endif %} diff --git a/mo/web/templates/acct_reset_passwd_wait.html b/mo/web/templates/acct_reset_passwd_wait.html new file mode 100644 index 00000000..59b155b3 --- /dev/null +++ b/mo/web/templates/acct_reset_passwd_wait.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block title %}Obnova hesla{% endblock %} +{% block body %} + +{% include "parts/wait_for_email.html" %} + +{% endblock %} diff --git a/mo/web/templates/acct_validate_email.html b/mo/web/templates/acct_validate_email.html index a801de71..1ead2626 100644 --- a/mo/web/templates/acct_validate_email.html +++ b/mo/web/templates/acct_validate_email.html @@ -14,19 +14,13 @@ {% else %} -<p> - Vyčkejte prosím, až vám přijde e-mail. -</p> +{% include "parts/wait_for_email.html" %} -<p> - Pakliže nepřichází: -</p> +{% endif %} -<div class="btn-group"> +<div class="btn-group space-top"> <a class='btn btn-default' href='{{ url_for('user_settings_personal') }}'>Změnit e-mail</a> <a class='btn btn-default' href="{{ url_for('user_settings') }}">Zpět na nastavení účtu</a> </div> -{% endif %} - {% endblock %} diff --git a/mo/web/templates/settings_change.html b/mo/web/templates/settings_change.html index 568ca0fa..c8e3a6dc 100644 --- a/mo/web/templates/settings_change.html +++ b/mo/web/templates/settings_change.html @@ -3,6 +3,18 @@ {% block title %}Změna osobních údajů{% endblock %} {% block body %} +{% if form %} + {{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }} +{% elif dsn_check_token %} + +{% include "parts/wait_for_email.html" %} + +<div class="btn-group space-top"> + <a class='btn btn-default' href="{{ url_for('user_settings') }}">Zpět na nastavení účtu</a> +</div> + +{% endif %} + {% endblock %} -- GitLab From aa1914bfadfd04ac95b6650778f48cda4b47cb7a Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 17:53:41 +0100 Subject: [PATCH 33/35] DSN: Autorizace API --- bin/send-dsn | 25 ++++++++++++++++++++++--- etc/config.py.example | 4 ++++ mo/web/api_dsn.py | 13 +++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/bin/send-dsn b/bin/send-dsn index e3cb49c2..6cc6785c 100755 --- a/bin/send-dsn +++ b/bin/send-dsn @@ -2,21 +2,40 @@ # Tento skript se volá při doručování pošty (například pomocí "execute" v Sieve) # a předá mail webové části OSMO přes /api/email-dsn. +import os +from pathlib import Path import requests from requests.exceptions import RequestException import sys if len(sys.argv) != 2: - print('Arguments: <URL of OSMO root>/', file=sys.stderr) + print('Arguments: <URL of OSMO root>', file=sys.stderr) sys.exit(1) osmo_url = sys.argv[1] mail = sys.stdin.buffer.read() +key_path = Path.home() / '.config/osmo/dsn-api-key' try: - reply = requests.post(f'{osmo_url}api/email-dsn', data=mail, timeout=30) -except RequestException: + with key_path.open() as f: + key = f.readline().strip() + if key == "": + print(f'Cannot read key from {key_path}', file=sys.stderr) + sys.exit(1) +except OSError as e: + print(f'Cannot read {key_path}: {e}', file=sys.stderr) + sys.exit(1) + +try: + reply = requests.post( + os.path.join(osmo_url, 'api/email-dsn'), + data=mail, + headers={'Authorization': f'Bearer {key}'}, + timeout=30) +except RequestException as e: + print(f'Error sending DSN: {e}') sys.exit(1) if reply.status_code != 200: + print(f'Error sending DSN: HTTP status {reply.status_code}') sys.exit(1) diff --git a/etc/config.py.example b/etc/config.py.example index 15d62304..a714932b 100644 --- a/etc/config.py.example +++ b/etc/config.py.example @@ -111,3 +111,7 @@ MAILING_LIST_EXCLUDE = { # Maximální počet e-mailových adres, které jsme ochotni ve webovém rozhraní zobrazit # uživatelům bez práva unrestricted_email. EMAILS_SHOW_MAX = 500 + +# Klíč k API na zpracování mailových nedoručenek. Měl by být také uložen +# v ~/.config/osmo/dsn-api-key účtu, který volá bin/send-dsn. Nesmí obsahovat mezery. +# DSN_API_KEY = "..." diff --git a/mo/web/api_dsn.py b/mo/web/api_dsn.py index 99194148..87bb85e0 100644 --- a/mo/web/api_dsn.py +++ b/mo/web/api_dsn.py @@ -112,8 +112,21 @@ def process_dsn_reg(dsn: db.EmailDSN) -> None: dsn.reg.dsn_id = dsn.dsn_id +def authorize_email_dsn() -> bool: + dsn_api_token = getattr(config, 'DSN_API_KEY', None) + auth_header = request.headers.get('Authorization') + if dsn_api_token is None or auth_header is None: + return False + + fields = auth_header.split() + return len(fields) == 2 and fields[1] == dsn_api_token + + @app.route('/api/email-dsn', methods=('POST',)) def api_email_dsn() -> Response: + if not authorize_email_dsn(): + raise werkzeug.exceptions.Forbidden() + body = request.get_data(cache=False) try: -- GitLab From 79b3b6fce4a05a66c89d4357e55e86ec816d8d18 Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 21:29:57 +0100 Subject: [PATCH 34/35] =?UTF-8?q?README:=20Trik=20pro=20kernel=20<5.4=20u?= =?UTF-8?q?=C5=BE=20nen=C3=AD=20pot=C5=99eba?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 71fea33e..2ffcab1a 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,6 @@ License verze 3 nebo novější (viz soubor LICENSE). ## Instalace na produkční server - # Pro systém s jádrem < 5.4 zvýšit net.core.somaxconn (jako root) - [ "`cat /proc/sys/net/core/somaxconn`" -lt 4096 ] && echo net.core.somaxconn=4096 >> /etc/sysctl.conf && sysctl -p - # Založit účet mo-web (jako root) adduser --system mo-web --shell /bin/bash loginctl enable-linger mo-web -- GitLab From 8dc000d472ac51725354bb30c874ebc7a396ec0d Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sat, 25 Jan 2025 22:02:42 +0100 Subject: [PATCH 35/35] DSN: Dokumentace --- README.md | 3 +++ doc/dsn.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 doc/dsn.md diff --git a/README.md b/README.md index 2ffcab1a..e7e5f833 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,9 @@ License verze 3 nebo novější (viz soubor LICENSE). # Na instalaci nové verze pak stačí spustit bin/deploy + # Chcete-li automaticky zpracovávat nedoručenky od mailů odeslaných OSMO, + # postupujte podle doc/dsn.md. + ## Mražení závislostí pip freeze | grep -v '^osmo=' >constraints.txt diff --git a/doc/dsn.md b/doc/dsn.md new file mode 100644 index 00000000..39abe106 --- /dev/null +++ b/doc/dsn.md @@ -0,0 +1,51 @@ +# Zpracování mailových nedoručenek (DSN) + +## Princip + +Kdykoliv OSMO odesílá mail, umí do obálkového FROM přidat token +(oddělený od jména uživatele pluskem), do kterého je zakódováno, +kterého uživatele nebo pokusu o registraci se mail týká. + +Pokud přijde nedoručenka, OSMO ji zpracuje a z tokenu v adrese +jejího adresáta určí, ke komu patří. K uživatelům a probíhajícím +registracím si pak poznamená informaci o nefunkčnosti e-mailové +adresy a umí ji uživateli zobrazit. + +## Implementace + +Nedoručenky se posílají na osmo-auto+token@mo.mff.cuni.cz, což se +přepíše na mo-web+dsn@gimli.ms.mff.cuni.cz. + +Účet mo-web má nakonfigurovaný následující filtr pro Sieve: + + require ["envelope", "subaddress", "include", "mailbox", "vnd.dovecot.execute"]; + + # Základní systémová pravidla + include :global "default"; + + if envelope :detail :matches "To" ["dsn", "dsn+*"] { + if execute :pipe "osmo-send-dsn" ["https://osmo.matematickaolympiada.cz/"] { + redirect "spravce+osmo-dsn-ok@example.org"; + } else { + redirect "spravce+osmo-dsn-error@example.org"; + } + } else { + redirect "spravce+osmo-unknown@example.org"; + } + +To spustí program bin/send-dsn, nainstalovaný jako +/usr/local/lib/dovecot/sieve-execute/osmo-send-dsn, +a ten předá nedoručenku webové části OSMO. Pokud zrovna webová část +neběží nebo neumí nedoručenku zpracovat, osmo-send-dsn selže, takže +Sieve nedoručenku přepošle správci. + +Webová část si vyparsuje token z hlavičkového To, takže interní +přeposílání během doručování ji nezmate. + +Skript bin/send-dsn je minimalistický, stačí mu Python se standardní +knihovnou Pythonu a balíčkem requests. Skript může běžet pod jiným +uživatelem, nebo dokonce na jiném stroji než zbytek OSMO. + +Komunikace mezi bin/send-dsn a zbytkem OSMO je autorizována tajným +klíčem. Web OSMO si ho přečte z DSN_API_KEY z configu, send-dsn +ho hledá jako jediný řádek souboru ~/.config/osmo/dsn-api-key. -- GitLab