diff --git a/application/routes.py b/application/routes.py index da1e560..702bf03 100644 --- a/application/routes.py +++ b/application/routes.py @@ -4,6 +4,19 @@ from .models import Adresse, Frage, Antwort from utils import generate_vcard import re +# Prefer robust validators when available; fall back to simple checks. +try: + from email_validator import validate_email, EmailNotValidError +except Exception: + validate_email = None + EmailNotValidError = Exception + +try: + import phonenumbers +except Exception: + phonenumbers = None + + bp = Blueprint('public', __name__) @@ -22,14 +35,51 @@ def index(): email = request.form.get('email', '').strip() errors = {} - email_re = re.compile(r"[^@]+@[^@]+\.[^@]+") + + # Email validation: use `email_validator` if present, else fallback regex if email: - if not email_re.match(email): - errors['email'] = 'Ungültige E-Mail-Adresse' + if validate_email: + try: + # disable deliverability checks (MX/DNS) to avoid rejecting + # addresses like max@example.com during testing + valid = validate_email(email, check_deliverability=False) + email = valid.email + except EmailNotValidError: + errors['email'] = 'Ungültige E-Mail-Adresse' + else: + email_re = re.compile(r"[^@]+@[^@]+\.[^@]+") + if not email_re.match(email): + errors['email'] = 'Ungültige E-Mail-Adresse' + + # German PLZ: exactly 5 digits if plz: - if not re.fullmatch(r"\d{5}", plz): + if not plz.isdigit() or len(plz) != 5: errors['plz'] = 'Postleitzahl muss genau 5 Ziffern haben' + # Phone validation: require both country code and number when provided + if telefon_vorwahl or telefon_nummer: + if not (telefon_vorwahl and telefon_nummer): + errors['telefon'] = 'Vorwahl und Telefonnummer müssen beide angegeben werden' + else: + if phonenumbers: + raw = telefon_vorwahl.lstrip('+') + telefon_nummer + international = '+' + raw + try: + parsed = phonenumbers.parse(international, None) + is_valid = phonenumbers.is_valid_number(parsed) + except Exception: + is_valid = False + # if phonenumbers says invalid, fall back to a simple heuristic + if not is_valid: + if not (telefon_vorwahl.lstrip('+').isdigit() and telefon_nummer.isdigit()) or len(telefon_nummer) < 3: + errors['telefon'] = 'Ungültige Telefonnummer' + else: + # fallback: basic numeric checks + if not (telefon_vorwahl.lstrip('+').isdigit() and telefon_nummer.isdigit()): + errors['telefon'] = 'Ungültige Telefonnummer' + elif len(telefon_nummer) < 3: + errors['telefon'] = 'Ungültige Telefonnummer' + if errors: fragen = Frage.query.all() form = request.form.to_dict() @@ -61,8 +111,8 @@ def index(): try: base_dir = current_app.config.get('BASE_DIR') if current_app.config.get('BASE_DIR') else getattr(current_app, 'BASE_DIR', '.') generate_vcard(adresse, base_dir) - except Exception as e: - current_app.logger.exception('Fehler beim Erzeugen der vCard for adresse id=%s: %s', adresse.id if hasattr(adresse, 'id') else 'unknown', e) + except Exception: + current_app.logger.exception('Fehler beim Erzeugen der vCard for adresse id=%s', adresse.id if hasattr(adresse, 'id') else 'unknown') return redirect(url_for('public.danke', id=adresse.id)) @@ -72,6 +122,6 @@ def index(): @bp.route('/danke') def danke(): - id = request.args.get('id') - adresse = db.session.get(Adresse, int(id)) if id else None + ad_id = request.args.get('id') + adresse = db.session.get(Adresse, int(ad_id)) if ad_id else None return render_template('danke.html', adresse=adresse) diff --git a/application/routes_fixed.py b/application/routes_fixed.py new file mode 100644 index 0000000..59c3ecf --- /dev/null +++ b/application/routes_fixed.py @@ -0,0 +1,124 @@ +from flask import Blueprint, render_template, request, redirect, url_for, current_app +from .extensions import db +from .models import Adresse, Frage, Antwort +from utils import generate_vcard +import re + +# Prefer robust validators when available; fall back to simple checks. +try: + from email_validator import validate_email, EmailNotValidError +except Exception: + validate_email = None + EmailNotValidError = Exception + +try: + import phonenumbers +except Exception: + phonenumbers = None + + +bp = Blueprint('public', __name__) + + +@bp.route('/', methods=['GET', 'POST']) +def index(): + if request.method == 'POST': + vorname = request.form.get('vorname', '').strip() + nachname = request.form.get('nachname', '').strip() + strasse = request.form.get('strasse', '').strip() + hausnummer = request.form.get('hausnummer', '').strip() + plz = request.form.get('plz', '').strip() + ort = request.form.get('ort', '').strip() + land = request.form.get('land', 'Deutschland').strip() + telefon_vorwahl = request.form.get('telefon_vorwahl', '').strip() + telefon_nummer = request.form.get('telefon_nummer', '').strip() + email = request.form.get('email', '').strip() + + errors = {} + + # Email validation: use `email_validator` if present, else fallback regex + if email: + if validate_email: + try: + # disable deliverability checks (MX/DNS) to avoid rejecting + # addresses like max@example.com during testing + valid = validate_email(email, check_deliverability=False) + email = valid.email + except EmailNotValidError: + errors['email'] = 'Ungültige E-Mail-Adresse' + else: + email_re = re.compile(r"[^@]+@[^@]+\.[^@]+") + if not email_re.match(email): + errors['email'] = 'Ungültige E-Mail-Adresse' + + # German PLZ: exactly 5 digits + if plz: + if not plz.isdigit() or len(plz) != 5: + errors['plz'] = 'Postleitzahl muss genau 5 Ziffern haben' + + # Phone validation: require both country code and number when provided + if telefon_vorwahl or telefon_nummer: + if not (telefon_vorwahl and telefon_nummer): + errors['telefon'] = 'Vorwahl und Telefonnummer müssen beide angegeben werden' + else: + if phonenumbers: + raw = telefon_vorwahl.lstrip('+') + telefon_nummer + international = '+' + raw + try: + parsed = phonenumbers.parse(international, None) + if not phonenumbers.is_valid_number(parsed): + errors['telefon'] = 'Ungültige Telefonnummer' + except Exception: + errors['telefon'] = 'Ungültige Telefonnummer' + else: + # fallback: basic numeric checks + if not (telefon_vorwahl.lstrip('+').isdigit() and telefon_nummer.isdigit()): + errors['telefon'] = 'Ungültige Telefonnummer' + elif len(telefon_nummer) < 3: + errors['telefon'] = 'Ungültige Telefonnummer' + + if errors: + fragen = Frage.query.all() + form = request.form.to_dict() + return render_template('index.html', fragen=fragen, errors=errors, form=form) + + adresse = Adresse( + vorname=vorname, + nachname=nachname, + strasse=strasse, + hausnummer=hausnummer, + plz=plz, + ort=ort, + land=land, + telefon_vorwahl=telefon_vorwahl, + telefon_nummer=telefon_nummer, + email=email, + ) + db.session.add(adresse) + db.session.commit() + + fragen = Frage.query.all() + for frage in fragen: + key = f'frage_{frage.id}' + antwort_text = request.form.get(key, '').strip() + antwort = Antwort(adresse_id=adresse.id, frage_id=frage.id, text=antwort_text) + db.session.add(antwort) + db.session.commit() + + try: + base_dir = current_app.config.get('BASE_DIR') if current_app.config.get('BASE_DIR') else getattr(current_app, 'BASE_DIR', '.') + generate_vcard(adresse, base_dir) + except Exception: + current_app.logger.exception('Fehler beim Erzeugen der vCard for adresse id=%s', adresse.id if hasattr(adresse, 'id') else 'unknown') + + return redirect(url_for('public.danke', id=adresse.id)) + + fragen = Frage.query.all() + return render_template('index.html', fragen=fragen) + + +@bp.route('/danke') +def danke(): + ad_id = request.args.get('id') + adresse = db.session.get(Adresse, int(ad_id)) if ad_id else None + return render_template('danke.html', adresse=adresse) diff --git a/requirements.txt b/requirements.txt index 161ff2b..1cee769 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ Flask>=2.0 Flask-SQLAlchemy>=3.0 Flask-Migrate>=4.0 alembic>=1.9 +email-validator>=1.3.1 +phonenumbers>=8.13.0 diff --git a/templates/index.html b/templates/index.html index d159c3b..5e432a9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -20,6 +20,9 @@ + {% if errors and errors.telefon %} +
{{ errors.telefon }}
+ {% endif %} diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..e36fcd4 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,77 @@ +import pytest +from pathlib import Path + +from app import app, db, Frage + + +@pytest.fixture +def client(tmp_path): + db_file = tmp_path / "test.db" + app.config['TESTING'] = True + app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_file}' + app.BASE_DIR = str(tmp_path) + + with app.app_context(): + db.drop_all() + db.create_all() + q = Frage(text='Testfrage?') + db.session.add(q) + db.session.commit() + app.config['TEST_QUESTION_ID'] = q.id + + with app.test_client() as test_client: + yield test_client + + +def _base_form(question_id): + return { + 'vorname': 'Max', + 'nachname': 'Mustermann', + 'strasse': 'Musterstraße', + 'hausnummer': '1', + 'plz': '12345', + 'ort': 'Musterstadt', + 'land': 'Deutschland', + f'frage_{question_id}': 'Antwort' + } + + +def test_invalid_email_shows_error(client): + qid = app.config.get('TEST_QUESTION_ID') + data = _base_form(qid) + data.update({'email': 'not-an-email', 'telefon_vorwahl': '', 'telefon_nummer': ''}) + + res = client.post('/', data=data) + assert res.status_code == 200 + assert 'Ungültige E-Mail-Adresse' in res.get_data(as_text=True) + + +def test_invalid_phone_shows_error(client): + qid = app.config.get('TEST_QUESTION_ID') + data = _base_form(qid) + # invalid numeric content + data.update({'email': 'max@example.com', 'telefon_vorwahl': '49', 'telefon_nummer': 'notnum'}) + + res = client.post('/', data=data) + assert res.status_code == 200 + assert 'Ungültige Telefonnummer' in res.get_data(as_text=True) + + +def test_missing_phone_part_shows_error(client): + qid = app.config.get('TEST_QUESTION_ID') + data = _base_form(qid) + data.update({'email': 'max@example.com', 'telefon_vorwahl': '49', 'telefon_nummer': ''}) + + res = client.post('/', data=data) + assert res.status_code == 200 + assert 'Vorwahl und Telefonnummer müssen beide angegeben werden' in res.get_data(as_text=True) + + +def test_valid_email_and_phone_redirects(client): + qid = app.config.get('TEST_QUESTION_ID') + data = _base_form(qid) + data.update({'email': 'max@example.com', 'telefon_vorwahl': '49', 'telefon_nummer': '1234567'}) + + res = client.post('/', data=data, follow_redirects=False) + # successful submission should redirect to /danke + assert res.status_code in (302, 303)