diff --git a/constraints.txt b/constraints.txt index b510a27c9fda073bb05db6da5ac65a024f9d54cf..18e05ba613732671a30c101753f10d1c621c7fc0 100644 --- a/constraints.txt +++ b/constraints.txt @@ -39,6 +39,7 @@ six==1.16.0 SQLAlchemy==1.4.40 sqlalchemy-stubs==0.4 sqlalchemy2-stubs==0.0.2a25 +token-bucket==0.3.0 tomli==2.0.1 types-bleach==5.0.3 types-Flask-SQLAlchemy==2.5.9 diff --git a/etc/config.py.example b/etc/config.py.example index dff58ab5b8d8dbfc9fc26045c110e58e59d00592..6883a276f39cbe9d612d19e2d56d82a680986da7 100644 --- a/etc/config.py.example +++ b/etc/config.py.example @@ -25,6 +25,9 @@ MAIL_CONTACT = "osmo@mo.mff.cuni.cz" # Odesilatel generovaných mailů (není-li definován, neposílají se) # MAIL_FROM = "osmo-auto@mo.mff.cuni.cz" +# Kam posíláme maily o interních chybách (není-li definováno, neposílají se) +# MAIL_ERRORS_TO = "osmo@mo.mff.cuni.cz" + # Pro testování je možné všechny odesílané maily přesměrovat na jinou adresu # MAIL_INSTEAD = "mares@kam.mff.cuni.cz" diff --git a/mo/email.py b/mo/email.py index 7b9a99e999698caa4ec700ccb71fa79c60039f68..d2fa1572952b2192241d1d2cce6484506cda35ff 100644 --- a/mo/email.py +++ b/mo/email.py @@ -5,16 +5,17 @@ import email.message import email.headerregistry import subprocess import textwrap +import token_bucket +import traceback +from typing import Mapping, Optional import urllib.parse import mo.db as db import mo.config as config -from mo.util import logger +from mo.util import logger, ExceptionInfo -def send_user_email(user: db.User, subject: str, body: str, add_footer: bool = False) -> bool: - logger.info(f'Mail: "{subject}" -> {user.email} (#{user.user_id})') - +def send_email(send_to: str, full_name: str, subject: str, body: str) -> bool: mail_from = getattr(config, 'MAIL_FROM', None) if mail_from is None: logger.error('Mail: V configu chybí nastavení MAIL_FROM') @@ -27,28 +28,21 @@ def send_user_email(user: db.User, subject: str, body: str, add_footer: bool = F ) msg['To'] = [ email.headerregistry.Address( - display_name=user.full_name(), - addr_spec=user.email, + display_name=full_name, + addr_spec=send_to, ) ] msg['Reply-To'] = email.headerregistry.Address( display_name='Správce OSMO', addr_spec=config.MAIL_CONTACT, ) - msg['Subject'] = 'OSMO – ' + subject + msg['Subject'] = subject msg['Date'] = datetime.datetime.now() - if add_footer: - body += "\n" + ("=" * 76) + "\n" - body += "Pokud nechcete tyto e-maily dostávat, vypněte si notifikace v nastavení\n" - body += f"svého účtu na {settings_url()}." - msg.set_content(body, cte='quoted-printable') mail_instead = getattr(config, 'MAIL_INSTEAD', None) - if mail_instead is None: - send_to = user.email - else: + if mail_instead is not None: send_to = mail_instead sm = subprocess.Popen( @@ -70,6 +64,17 @@ def send_user_email(user: db.User, subject: str, body: str, add_footer: bool = F return True +def send_user_email(user: db.User, subject: str, body: str, add_footer: bool = False) -> bool: + logger.info(f'Mail: "{subject}" -> {user.email} (#{user.user_id})') + + if add_footer: + body += "\n" + ("=" * 76) + "\n" + body += "Pokud nechcete tyto e-maily dostávat, vypněte si notifikace v nastavení\n" + body += f"svého účtu na {settings_url()}." + + return send_email(user.email, user.full_name(), 'OSMO – ' + subject, body) + + def activate_url(token: str) -> str: return config.WEB_ROOT + 'acct/activate?' + urllib.parse.urlencode({'token': token}, safe=':') @@ -175,3 +180,36 @@ def send_grading_info_email(dest: db.User, round: db.Round) -> bool: Váš OSMO '''), add_footer=True) + + +# Omezení rychlosti odesílání mailů o chybách děravým kyblíkem +# (pozor, každý proces má svůj kyblík) +error_tbf = token_bucket.Limiter(rate=1/300, capacity=10, storage=token_bucket.MemoryStorage()) +errors_supressed = 0 + + +def send_internal_error_email(place: str, attrs: Mapping[str, Optional[str]], exc_info: ExceptionInfo): + errors_to = getattr(config, 'MAIL_ERRORS_TO', None) + if errors_to is None: + return + + global errors_supressed + if not error_tbf.consume(key="K"): + errors_supressed += 1 + logger.info('Mail: Příliš mnoho chybových zpráv') + return + + exc_type, exc_value, exc_traceback = exc_info + + lines = [f'{key+":":10s} {val if val is not None else "-"}\n' for key, val in sorted(attrs.items())] + + if exc_traceback is not None: + lines.append('\n') + lines.extend(traceback.format_exception(exc_type, exc_value, exc_traceback)) + + if errors_supressed > 0: + lines.append('\n') + lines.append(f'### Předchozích {errors_supressed} chyb neohlášeno\n') + + logger.info('Mail: Zpráva o interní chybě') + send_email(errors_to, "", f'Interní chyba ({place})', "".join(lines)) diff --git a/mo/jobs/__init__.py b/mo/jobs/__init__.py index 56e68e5f40d390c1781c6060f9520f296195d477..67bff065b55cdb901fbc81829f5a232097848721 100644 --- a/mo/jobs/__init__.py +++ b/mo/jobs/__init__.py @@ -4,14 +4,16 @@ from datetime import timedelta import os import shutil from sqlalchemy import or_ +import sys from typing import Optional, Dict, Callable, List import mo import mo.config as config import mo.db as db +import mo.email import mo.rights import mo.util -from mo.util import logger +from mo.util import logger, ExceptionInfo def send_notify(): @@ -141,6 +143,8 @@ class TheJob: job.out_file = None sess.commit() + exc_info: ExceptionInfo = (None, None, None) + try: self.gatekeeper = mo.rights.Gatekeeper(job.user) self.expires_in_minutes = config.JOB_EXPIRATION @@ -159,7 +163,8 @@ class TheJob: logger.info(f'Job: Úspěšně dokončen job #{job.job_id} ({job.result})') job.state = db.JobState.done except Exception as e: - logger.error(f'Job: Chyba při zpracování jobu #{job.job_id}: %s', e, exc_info=e) + exc_info = sys.exc_info() + logger.error(f'Job: Chyba při zpracování jobu #{job.job_id}: %s', e, exc_info=exc_info) job.state = db.JobState.internal_error job.result = 'Interní chyba, informujte prosím správce systému.' @@ -168,6 +173,16 @@ class TheJob: job.expires_at = job.finished_at + timedelta(minutes=self.expires_in_minutes) sess.commit() + # Maily o interních chybách posíláme mimo transakci + if job.state == db.JobState.internal_error: + err_attrs = { + 'Job': f'#{job.job_id}', + 'Type': str(job.type), + 'User': f'#{job.user_id}', + 'Desc': job.description, + } + mo.email.send_internal_error_email(f'Job #{job.job_id}', err_attrs, exc_info) + def process_jobs(): sess = db.get_session() diff --git a/mo/util.py b/mo/util.py index ede00bc0375a1ee9d7da29ec5a6f2956528d8ce3..a5fec2e3d3831da784b87b1e6a653655e5ce0338 100644 --- a/mo/util.py +++ b/mo/util.py @@ -10,7 +10,8 @@ import os import re import secrets import sys -from typing import Any, Optional, NoReturn, Tuple, TypeVar, List +from types import TracebackType +from typing import Any, Optional, NoReturn, Tuple, TypeVar, List, Union import mo import mo.db as db @@ -25,6 +26,9 @@ logger = logging.getLogger('mo') logger.setLevel(logging.DEBUG) logger.propagate = True +# Typ vracený sys.exc_info() +ExceptionInfo = Union[Tuple[type, BaseException, TracebackType], Tuple[None, None, None]] + def get_now() -> datetime.datetime: return datetime.datetime.now(tz=dateutil.tz.UTC) diff --git a/mo/web/__init__.py b/mo/web/__init__.py index c15c9abb0777718a37e93c534c57efc11f8a125d..c7eb334ada58c52337d76f35b15eedf52aeb16fb 100644 --- a/mo/web/__init__.py +++ b/mo/web/__init__.py @@ -13,6 +13,7 @@ import werkzeug.formparser import mo import mo.config as config +import mo.email import mo.db as db import mo.ext.assets import mo.jobs @@ -22,6 +23,19 @@ import mo.users import mo.util +class MOFlask(Flask): + + def log_exception(self, exc_info: mo.util.ExceptionInfo) -> None: + err_attrs = { + 'Method': request.method, + 'Path': request.path, + 'Referrer': request.referrer, + 'Client': request.remote_addr, + } + mo.email.send_internal_error_email(request.path, err_attrs, exc_info) + super().log_exception(exc_info) + + # Ohýbáme Flask, aby uploadované soubory ukládal do adresáře podle našeho přání, # aby se pak daly zařadit mezi datové soubory prostým hardlinkováním. Za tímto účelem # subclassujeme Request, aby použil subclassovaný FormDataParser, který použije naši @@ -64,7 +78,7 @@ mo.config.DATA_DIR = os.path.abspath(mo.config.DATA_DIR) static_dir = os.path.abspath('static') # Aplikační objekt -app = Flask(__name__, static_folder=static_dir) +app = MOFlask(__name__, static_folder=static_dir) app.config.from_object(config) app.request_class = Request db.flask_db = SQLAlchemy(app, metadata=db.metadata) diff --git a/setup.py b/setup.py index 41d797367b7b28eac72e96f6a48f0ce357441feb..418460678597f1bd540b50de2722ab61bc436aab 100644 --- a/setup.py +++ b/setup.py @@ -48,6 +48,7 @@ setuptools.setup( 'pyzbar', 'requests', 'sqlalchemy[mypy]', + 'token-bucket', 'uwsgidecorators', # Používáme pro vývoj, ale aby je pylsp našel, musí být ve stejném virtualenvu # jako ostatní knihovny.