From 377e379883b90c88e5528064af6634e2f1a061bb Mon Sep 17 00:00:00 2001 From: ske087 Date: Sat, 26 Jul 2025 18:50:42 +0300 Subject: [PATCH] Finalize mail settings admin UI and Mailrise compatibility --- .env.example | 6 + app/__init__.py | 18 +++ app/models.py | 32 +++++ app/routes/admin.py | 79 +++++++++++- app/routes/auth.py | 46 +++++-- app/routes/mail.py | 2 + app/routes/reset_password.py | 12 ++ app/static/map_iframe_single.html | 39 ++---- app/templates/admin/base.html | 5 + app/templates/admin/mail_settings.html | 66 ++++++++++ app/templates/admin/user_detail.html | 13 ++ app/templates/auth/forgot_password.html | 27 +++++ app/templates/auth/reset_password.html | 38 ++++++ app/utils/token.py | 19 +++ config.py | 1 + create_admin.py | 25 ++++ migrations/README | 1 + migrations/add_media_folder.py | 28 ----- migrations/alembic.ini | 50 ++++++++ migrations/create_map_routes.py | 103 ---------------- migrations/env.py | 113 ++++++++++++++++++ migrations/script.py.mako | 24 ++++ ...add_mail_settings_and_sent_email_tables.py | 52 ++++++++ 23 files changed, 629 insertions(+), 170 deletions(-) create mode 100644 app/routes/mail.py create mode 100644 app/routes/reset_password.py create mode 100644 app/templates/admin/mail_settings.html create mode 100644 app/templates/auth/forgot_password.html create mode 100644 app/templates/auth/reset_password.html create mode 100644 app/utils/token.py create mode 100644 create_admin.py create mode 100644 migrations/README delete mode 100644 migrations/add_media_folder.py create mode 100644 migrations/alembic.ini delete mode 100644 migrations/create_map_routes.py create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/df85d77e2474_add_mail_settings_and_sent_email_tables.py diff --git a/.env.example b/.env.example index e69de29..58a3d54 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,6 @@ +# Secret for password reset tokens +RESET_TOKEN_SECRET=your-very-secret-reset-token-key +# Example .env for admin creation +ADMIN_EMAIL=admin@example.com +ADMIN_NICKNAME=admin +ADMIN_PASSWORD=changeme diff --git a/app/__init__.py b/app/__init__.py index c50e275..ce0dfab 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -92,5 +92,23 @@ def create_app(config_name=None): os.makedirs(upload_dir, exist_ok=True) os.makedirs(os.path.join(upload_dir, 'images'), exist_ok=True) os.makedirs(os.path.join(upload_dir, 'gpx'), exist_ok=True) + + # --- Initial Admin Creation from .env --- + from app.models import User + with app.app_context(): + admin_email = os.environ.get('ADMIN_EMAIL') + admin_nickname = os.environ.get('ADMIN_NICKNAME') + admin_password = os.environ.get('ADMIN_PASSWORD') + if admin_email and admin_nickname and admin_password: + if not User.query.filter_by(email=admin_email).first(): + user = User(nickname=admin_nickname, email=admin_email, is_admin=True, is_active=True) + user.set_password(admin_password) + db.session.add(user) + db.session.commit() + print(f"[INFO] Admin user {admin_nickname} <{admin_email}> created from .env.") + else: + print(f"[INFO] Admin with email {admin_email} already exists.") + else: + print("[INFO] ADMIN_EMAIL, ADMIN_NICKNAME, or ADMIN_PASSWORD not set in .env. Skipping admin creation.") return app diff --git a/app/models.py b/app/models.py index 5994610..71b1832 100644 --- a/app/models.py +++ b/app/models.py @@ -273,3 +273,35 @@ class PageView(db.Model): def __repr__(self): return f'' + + +# --- Mail Server Management Models --- +class MailSettings(db.Model): + __tablename__ = 'mail_settings' + id = db.Column(db.Integer, primary_key=True) + enabled = db.Column(db.Boolean, default=False, nullable=False) + server = db.Column(db.String(255), nullable=False) + port = db.Column(db.Integer, nullable=False) + use_tls = db.Column(db.Boolean, default=True, nullable=False) + username = db.Column(db.String(255)) + password = db.Column(db.String(255)) + default_sender = db.Column(db.String(255)) + provider = db.Column(db.String(50), default='smtp') # 'smtp' or 'mailrise' + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f'' + +class SentEmail(db.Model): + __tablename__ = 'sent_emails' + id = db.Column(db.Integer, primary_key=True) + recipient = db.Column(db.String(255), nullable=False) + subject = db.Column(db.String(255), nullable=False) + body = db.Column(db.Text, nullable=False) + status = db.Column(db.String(50), default='sent') # sent, failed, etc. + error = db.Column(db.Text) + sent_at = db.Column(db.DateTime, default=datetime.utcnow) + provider = db.Column(db.String(50), default='smtp') + + def __repr__(self): + return f'' diff --git a/app/routes/admin.py b/app/routes/admin.py index e4273ee..5ee188a 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,13 +1,88 @@ + + from flask import Blueprint, render_template, request, flash, redirect, url_for, jsonify, current_app +from flask_mail import Message from flask_login import login_required, current_user from functools import wraps from datetime import datetime, timedelta from sqlalchemy import func, desc +import secrets +from app.routes.mail import mail from app.extensions import db -from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView +from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView, MailSettings, SentEmail admin = Blueprint('admin', __name__, url_prefix='/admin') +def admin_required(f): + """Decorator to require admin access""" + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or not current_user.is_admin: + flash('Admin access required.', 'error') + return redirect(url_for('auth.login')) + return f(*args, **kwargs) + return decorated_function + +@admin.route('/mail-settings', methods=['GET', 'POST']) +@login_required +@admin_required +def mail_settings(): + settings = MailSettings.query.first() + if request.method == 'POST': + enabled = bool(request.form.get('enabled')) + provider = request.form.get('provider') + server = request.form.get('server') + port = int(request.form.get('port') or 0) + use_tls = bool(request.form.get('use_tls')) + username = request.form.get('username') + password = request.form.get('password') + default_sender = request.form.get('default_sender') + if not settings: + settings = MailSettings() + db.session.add(settings) + settings.enabled = enabled + settings.provider = provider + settings.server = server + settings.port = port + settings.use_tls = use_tls + settings.username = username + settings.password = password + settings.default_sender = default_sender + db.session.commit() + flash('Mail settings updated.', 'success') + sent_emails = SentEmail.query.order_by(SentEmail.sent_at.desc()).limit(50).all() + return render_template('admin/mail_settings.html', settings=settings, sent_emails=sent_emails) + + +## Duplicate imports and Blueprint definitions removed + +# Password reset token generator (simple, for demonstration) +def generate_reset_token(user): + # In production, use itsdangerous or Flask-Security for secure tokens + return secrets.token_urlsafe(32) + +# Admin: Send password reset email to user +@admin.route('/users//reset-password', methods=['POST']) +@login_required +@admin_required +def reset_user_password(user_id): + user = User.query.get_or_404(user_id) + token = generate_reset_token(user) + # In production, save token to DB or cache, and validate on reset + reset_url = url_for('auth.reset_password', token=token, _external=True) + msg = Message( + subject="Password Reset Request", + recipients=[user.email], + body=f"Hello {user.nickname},\n\nAn admin has requested a password reset for your account. Click the link below to reset your password:\n{reset_url}\n\nIf you did not request this, please ignore this email." + ) + try: + mail.send(msg) + flash(f"Password reset email sent to {user.email}.", "success") + except Exception as e: + current_app.logger.error(f"Error sending reset email: {e}") + flash(f"Failed to send password reset email: {e}", "danger") + return redirect(url_for('admin.user_detail', user_id=user.id)) + def admin_required(f): """Decorator to require admin access""" @wraps(f) @@ -98,7 +173,7 @@ def dashboard(): def posts(): """Admin post management - review posts""" page = request.args.get('page', 1, type=int) - status = request.args.get('status', 'pending') # pending, published, all + status = request.args.get('status', 'all') # pending, published, all query = Post.query diff --git a/app/routes/auth.py b/app/routes/auth.py index f981e1f..9f412b3 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -3,8 +3,12 @@ from flask_login import login_user, logout_user, login_required, current_user from werkzeug.security import check_password_hash from app.models import User, db from app.forms import LoginForm, RegisterForm, ForgotPasswordForm +from app.routes.reset_password import RequestResetForm, ResetPasswordForm +from flask_mail import Message +from app.routes.mail import mail +from app.utils.token import generate_reset_token, verify_reset_token import re - +from app.forms import LoginForm, RegisterForm, ForgotPasswordForm auth = Blueprint('auth', __name__) @auth.route('/login', methods=['GET', 'POST']) @@ -83,18 +87,44 @@ def forgot_password(): """Forgot password page""" if current_user.is_authenticated: return redirect(url_for('main.index')) - - form = ForgotPasswordForm() + form = RequestResetForm() if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data).first() if user: - # TODO: Implement email sending for password reset - flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info') - else: - flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info') + token = generate_reset_token(user.email) + reset_url = url_for('auth.reset_password', token=token, _external=True) + msg = Message( + subject="Password Reset Request", + recipients=[user.email], + body=f"Hello {user.nickname},\n\nTo reset your password, click the link below:\n{reset_url}\n\nIf you did not request this, please ignore this email." + ) + try: + mail.send(msg) + except Exception as e: + flash(f"Failed to send reset email: {e}", "danger") + flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info') return redirect(url_for('auth.login')) - return render_template('auth/forgot_password.html', form=form) +# Password reset route +@auth.route('/reset-password/', methods=['GET', 'POST']) +def reset_password(token): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + email = verify_reset_token(token) + if not email: + flash('Invalid or expired reset link.', 'danger') + return redirect(url_for('auth.forgot_password')) + user = User.query.filter_by(email=email).first() + if not user: + flash('Invalid or expired reset link.', 'danger') + return redirect(url_for('auth.forgot_password')) + form = ResetPasswordForm() + if form.validate_on_submit(): + user.set_password(form.password.data) + db.session.commit() + flash('Your password has been reset. You can now log in.', 'success') + return redirect(url_for('auth.login')) + return render_template('auth/reset_password.html', form=form) def is_valid_password(password): """Validate password strength""" diff --git a/app/routes/mail.py b/app/routes/mail.py new file mode 100644 index 0000000..8feb6db --- /dev/null +++ b/app/routes/mail.py @@ -0,0 +1,2 @@ +from flask_mail import Mail +mail = Mail() diff --git a/app/routes/reset_password.py b/app/routes/reset_password.py new file mode 100644 index 0000000..4d9ef80 --- /dev/null +++ b/app/routes/reset_password.py @@ -0,0 +1,12 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SubmitField +from wtforms.validators import DataRequired, Email, EqualTo, Length + +class RequestResetForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + submit = SubmitField('Request Password Reset') + +class ResetPasswordForm(FlaskForm): + password = PasswordField('New Password', validators=[DataRequired(), Length(min=6)]) + confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('Reset Password') diff --git a/app/static/map_iframe_single.html b/app/static/map_iframe_single.html index e199e89..9d25c7c 100644 --- a/app/static/map_iframe_single.html +++ b/app/static/map_iframe_single.html @@ -38,17 +38,24 @@ window.addEventListener('resize', resizeMap); resizeMap(); // Add OSM tiles -L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: 'Ā© OpenStreetMap contributors' +// Use CartoDB Positron for English map labels (clean, readable, English by default) +L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + attribution: 'Ā© OpenStreetMap contributors & CartoDB', + subdomains: 'abcd', + maxZoom: 19 }).addTo(map); +// Ensure map is always north-up (default in Leaflet) +// Prevent any future rotation plugins or gestures +map.dragRotate && map.dragRotate.disable && map.dragRotate.disable(); +map.touchZoomRotate && map.touchZoomRotate.disableRotation && map.touchZoomRotate.disableRotation(); // Fetch route data if (routeId) { fetch(`/community/api/route/${routeId}`) .then(response => response.json()) .then(data => { if (data && data.coordinates && data.coordinates.length > 0) { - const latlngs = data.coordinates.map(pt => [pt[1], pt[0]]); + const latlngs = data.coordinates.map(pt => [pt[0], pt[1]]); const polyline = L.polyline(latlngs, { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(map); map.fitBounds(polyline.getBounds(), { padding: [20, 20], maxZoom: 15 }); // Start marker @@ -70,30 +77,4 @@ if (routeId) { } - - color: '#f97316', weight: 5, opacity: 0.9, smoothFactor: 1 - }).addTo(routeLayer); - polyline.bindPopup(`

šŸļø ${route.title}

Distance: ${route.distance.toFixed(2)} km
Elevation Gain: ${route.elevation_gain.toFixed(0)} m
`); - // Always fit bounds to the route and bind to frame size - function fitRouteBounds() { - map.invalidateSize(); - map.fitBounds(polyline.getBounds(), { padding: [10, 10], maxZoom: 18 }); - } - fitRouteBounds(); - window.addEventListener('resize', fitRouteBounds); - }) - .catch(error => showError(error.message)); - } - function showError(msg) { - const loading = document.getElementById('map-loading'); - loading.innerHTML = `
āš ļø
${msg}
`; - } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeMap); - } else { - initializeMap(); - } - window.addEventListener('resize', () => { if (map) setTimeout(() => map.invalidateSize(), 100); }); - - diff --git a/app/templates/admin/base.html b/app/templates/admin/base.html index e0fa4a0..5c2071c 100644 --- a/app/templates/admin/base.html +++ b/app/templates/admin/base.html @@ -173,6 +173,11 @@ Analytics +