diff --git a/README.md b/README.md index 71fea33eb5b0a5fe672110fa8559fa7b779f2fc6..e7e5f833bd7f1cf00cfa61abd3bc92621c708b56 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 @@ -87,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/bin/send-dsn b/bin/send-dsn index e3cb49c2e3ec6788bc284f3439ff9f01c56c47d8..6cc6785c493fbf78dfabcad906adbaeb3883d1f6 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/db/db.ddl b/db/db.ddl index 8fa36b32660939a5c85ca30d3f5e52b9ab457385..7d2775a8df480fe816865dba3712c9a1e34ff9e4 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) @@ -402,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 ( @@ -415,6 +418,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 +510,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 0000000000000000000000000000000000000000..9ca7e5862e6ffa9df4e92863c5311d241666f237 --- /dev/null +++ b/db/upgrade-20250123.sql @@ -0,0 +1,23 @@ +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; + +ALTER TYPE reg_req_type ADD VALUE 'validate_email'; diff --git a/doc/dsn.md b/doc/dsn.md new file mode 100644 index 0000000000000000000000000000000000000000..39abe1062391196c302d9f0c67bfbfbf92cbcb42 --- /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. diff --git a/etc/config.py.example b/etc/config.py.example index 9c02e6220eba28e46892afe3c92af960ed968e39..a714932b78127839cda2fd3615ccb0808caba96c 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 @@ -108,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/db.py b/mo/db.py index 4e2dfe2a2368aaa5cf6824c9dd5887c72f5cc4e5..6be1ff0d57f14e75564071a3716cc312e6065f80 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 @@ -465,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)""" @@ -918,6 +924,7 @@ class RegReqType(MOEnum): register = auto() change_email = auto() reset_passwd = auto() + validate_email = auto() class RegRequest(Base): @@ -932,9 +939,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 +1054,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 diff --git a/mo/email.py b/mo/email.py index 7e8167242c1ac1d3f24db39a731ac55f9fb7a1c0..78684545d2c23be6a41700618eb8c658e2c125a9 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: @@ -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/jobs/notify.py b/mo/jobs/notify.py index ea522b7d34a38c44abe1059ddf1f70012167f4c7..50097deaa70e6f14f6662bdd7f558480faf5bcbc 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/users.py b/mo/users.py index 2b702c70f19d63a12eb28d50bba7b6246f2331e6..ef89b1ae49b0224fbfc7360b1ac8e782f0438c58 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) diff --git a/mo/util_dsn.py b/mo/util_dsn.py new file mode 100644 index 0000000000000000000000000000000000000000..165530f055f47fee780bcfe5bc2f9dc5bbcf8e33 --- /dev/null +++ b/mo/util_dsn.py @@ -0,0 +1,60 @@ +# 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 +from mo.util import logger + + +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 + + +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() + + +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/__init__.py b/mo/web/__init__.py index e8dbd2277d57709f546f74935c428234a1e86e6a..f8ccc040f8ed3cb984b97e144e9190f76faef56d 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): @@ -191,6 +192,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) @@ -205,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') @@ -260,6 +268,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/acct.py b/mo/web/acct.py index 02c829287ee7423dce89ea871897e88fa7bc5d62..c76eff4bbee543f4615af38f157832d8dde76653 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 @@ -59,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: @@ -72,8 +73,9 @@ 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') + 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): @@ -96,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() @@ -173,18 +180,25 @@ 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 - if form.validate_on_submit(): - ok = True + 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 None + 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 +209,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 None else: rr = mo.users.new_reg_request(db.RegReqType.change_email, request.remote_addr) if rr: @@ -209,12 +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') - ok = False - if ok: - return redirect(url_for('user_settings')) + return None + return 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) @@ -409,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: @@ -452,12 +471,18 @@ 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: 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: @@ -523,9 +548,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,10 +574,16 @@ class Reg2: }, ) - self.rr.used_at = mo.now - sess.commit() + 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.util_dsn.reset_user_dsn(user) + self.spend_request_and_commit() + def change_passwd(self, new_passwd: str): sess = db.get_session() user = self.rr.user @@ -562,11 +591,10 @@ 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.util_dsn.reset_user_dsn(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,12 +613,13 @@ 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(): +@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: @@ -610,29 +639,29 @@ 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') 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(): +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(): 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')) @@ -640,14 +669,45 @@ 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): 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 +722,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')) @@ -674,6 +734,89 @@ 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) + + +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/<dsn_check_token>') +def user_validate_email(dsn_check_token: Optional[str] = None): + 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 not dsn_check_token: + 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) + 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', dsn_check_token=dsn_check_token, _method='GET')) + else: + form = None + + return render_template('acct_validate_email.html', form=form, dsn_check_token=dsn_check_token) + + +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/admin.py b/mo/web/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..c31dc9d0009569bfe6c9c155912d6dceca07a68f --- /dev/null +++ b/mo/web/admin.py @@ -0,0 +1,24 @@ +# 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 +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) + .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, admin_list=True) diff --git a/mo/web/api_dsn.py b/mo/web/api_dsn.py index 64de762c82662260dbc34b36ff9b9f8eb34d8d41..87bb85e061bb9a55849a1c04e23376db1f96a964 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,77 @@ 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 + + +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: - # FIXME: Authorization? - # FIXME: Add in Flask 3.1: request.max_content_length = 1048576 + if not authorize_email_dsn(): + raise werkzeug.exceptions.Forbidden() + 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({}) diff --git a/mo/web/jinja.py b/mo/web/jinja.py index 96515a7c1e0ba75b85b03f806a2a3eb68351637f..6df9522b145eeed32face2372f84428444e8bdcd 100644 --- a/mo/web/jinja.py +++ b/mo/web/jinja.py @@ -14,10 +14,11 @@ 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 -from mo.web.util import user_html_flags +from mo.web.util import user_html_flags, user_url # Konfigurace Jinjy @@ -69,20 +70,15 @@ 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) + @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/org_users.py b/mo/web/org_users.py index 11e0240d810743fa9cae21869a7917f25e75dc4f..be60c783a6446dca3effc800f4cd187d2d354be0 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 @@ -524,8 +525,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.util_dsn.reset_user_dsn(user) + app.logger.info(f"Uživatel #{id} změněn: {changes}") mo.util.log( type=db.LogType.user, what=id, @@ -822,3 +824,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/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 0000000000000000000000000000000000000000..3316e1b0022bb5b99c367d4c690c005b1ed6f94b --- /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_dsn.html b/mo/web/templates/acct_dsn.html new file mode 100644 index 0000000000000000000000000000000000000000..ba0d4a38fccaecede7541ed82323ed12816a16a9 --- /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 %} diff --git a/mo/web/templates/acct_dsn_check.html b/mo/web/templates/acct_dsn_check.html new file mode 100644 index 0000000000000000000000000000000000000000..5b14c3549c2429c01239a7e824e53dba3a705503 --- /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/acct_reg2.html b/mo/web/templates/acct_reg2.html index a0f6eeaa2d05203bca0844499ba4e53c9bb56557..3b7ddc0be175cb75d04c00eac1e040696008cf9b 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 0000000000000000000000000000000000000000..59b155b39248d875a546f0315fc62e6c9bcb9ee0 --- /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 new file mode 100644 index 0000000000000000000000000000000000000000..1ead262662664a734838565f927b2c6384182ab1 --- /dev/null +++ b/mo/web/templates/acct_validate_email.html @@ -0,0 +1,26 @@ +{% 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 %} + +{% include "parts/wait_for_email.html" %} + +{% endif %} + +<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> + +{% endblock %} diff --git a/mo/web/templates/admin_dsn.html b/mo/web/templates/admin_dsn.html new file mode 100644 index 0000000000000000000000000000000000000000..2a08bef6520055a60b6a075227d7d98dd99ba159 --- /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 %} diff --git a/mo/web/templates/org_generic_list.html b/mo/web/templates/org_generic_list.html index 106a0342ffa17cb7e7822f0cafa0593cf62419a4..e6c2e900dbd7e7b2a8acc9f7a1781d54a6fe1a69 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_index.html b/mo/web/templates/org_index.html index d14d84b70f4359936e83bca38535b1deafc223a8..aa72dcd35b1cd79720ea01623375542fae35ad54 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> @@ -82,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 %} diff --git a/mo/web/templates/org_org.html b/mo/web/templates/org_org.html index beb75788f1b1210fda4e4ba11d88e69b50a1dbe2..b2b96baadcc056bfb08a3eaae2386671006aaa1d 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_orgs.html b/mo/web/templates/org_orgs.html index 6bbe924a00e17eb8e086485134f0652ccd93942e..5ee560b45412322f5fea132943a6ccf777209a5c 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_place_roles.html b/mo/web/templates/org_place_roles.html index dfaaa739eebd66b05b367598e5a7c035fbdbeb64..7f9fa673067024b61c3c7f4d74f07c95775328eb 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 '–' }} diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html index 313a25696dc925a0cf221c45cb3fd8fd9f022fcf..301211481b1c5b610b4f1f4c2b71baebc9b2f564 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_dsn.html b/mo/web/templates/org_user_dsn.html new file mode 100644 index 0000000000000000000000000000000000000000..f766a824b374ec25dc6a640c6f1b0c9c4209df7d --- /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/org_user_edit.html b/mo/web/templates/org_user_edit.html index 4d78b0772c87e6ebd98eb1d03a95a96e8c3409ec..f2f7e5aa0111f74e0896cc35eed1d45ffb86228b 100644 --- a/mo/web/templates/org_user_edit.html +++ b/mo/web/templates/org_user_edit.html @@ -6,13 +6,17 @@ <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> </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> diff --git a/mo/web/templates/org_users.html b/mo/web/templates/org_users.html index 82aaf3a79e83758fc24f93ce5b17d8c505f8a286..198e5b6531df46faef0a16b5d55ea3f5b9746348 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/templates/parts/check_dsn.html b/mo/web/templates/parts/check_dsn.html new file mode 100644 index 0000000000000000000000000000000000000000..d32bb5d24ba8b1dbc65a35854b11748414b66be5 --- /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/parts/dsn.html b/mo/web/templates/parts/dsn.html new file mode 100644 index 0000000000000000000000000000000000000000..06d0da2659b19cdadb04f5c85d81009028063dd4 --- /dev/null +++ b/mo/web/templates/parts/dsn.html @@ -0,0 +1,40 @@ +{# Výpis mailových nedoručenek #} + +{% for dsn in dsns %} + +<table class=data> + <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 }} + <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 %} 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 0000000000000000000000000000000000000000..e281d29887cc83412582ee6b6fd3a8d684d18d8f --- /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/mo/web/templates/settings.html b/mo/web/templates/settings.html index 50335f12b36d2aaae770f8dedb7ac131278de930..14350ca398072c29d16743f6f53f1232a0347510 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/settings_change.html b/mo/web/templates/settings_change.html index 568ca0fa6402ee006d649222486e398baaf91e74..c8e3a6dcd00f2a2f60ecc452bef9c06d13c03c00 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 %} diff --git a/mo/web/templates/user_index.html b/mo/web/templates/user_index.html index ad3bf1375e9b092f1a31baacbaff7adb5a4456db..46d89241a41efe3881d7271960a0f6d0e7cd3109 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 d6af6b7349d54d82f436fc3ba7394966da6e3e06..c45c8e621f1040b0c9b7032680c84f3234f73ed3 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. diff --git a/mo/web/user.py b/mo/web/user.py index 17364b2b8e36317c5c83ac9158a98422f87d3995..d39a61df97febe827d3a52c7f333058c865cd30c 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 diff --git a/mo/web/util.py b/mo/web/util.py index 82a43235a5cfb93f777a933794c1acf52e59f127..245438eb496320a6746353bb7d428665ee033bad 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) @@ -41,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: @@ -147,8 +160,17 @@ 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: 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) diff --git a/static/mo.css b/static/mo.css index ce1867abd85d1c0e26f60272d45741040ff9f1a8..655fbfecbd27b6eb226224ddfb72299156cd504e 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 */ @@ -591,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) {