import os import uuid import threading import logging from flask import Flask, request, render_template, redirect, url_for, flash from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user from werkzeug.security import generate_password_hash, check_password_hash from dotenv import load_dotenv import psycopg2 from mastodon import Mastodon import schedule as sch import time as t from forms import LoginForm, RegistrationForm from models import db, User, Toot from sqlalchemy.orm import Session from flask_wtf import CSRFProtect # Load environment variables from .env file load_dotenv() # Initialize logging logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) app = Flask(__name__) # Securely configure the app secret key app.secret_key = os.urandom(24) # Initialize CSRF Protection csrf = CSRFProtect() csrf.init_app(app) # Configure app SERVER_NAME to support url_for outside requests app.config['SERVER_NAME'] = 'toot.themediahub.org:5010' app.config['APPLICATION_ROOT'] = '/' app.config['PREFERRED_URL_SCHEME'] = 'http' # Database configuration db_user = os.getenv('DB_USER', 'your_db_user') db_password = os.getenv('DB_PASSWORD', 'your_db_password') db_name = os.getenv('DB_NAME', 'tootdb') db_host_primary = os.getenv('DB_HOST_PRIMARY', 'db3.cluster.doctatortot.com') db_host_failover = os.getenv('DB_HOST_FAILOVER', 'db4.cluster.doctatortot.com') def get_database_uri(host): return f'postgresql://{db_user}:{db_password}@{host}/{db_name}' def create_db_session(): app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False for host in [db_host_primary, db_host_failover]: try: app.config['SQLALCHEMY_DATABASE_URI'] = get_database_uri(host) db.init_app(app) conn = psycopg2.connect(get_database_uri(host)) conn.close() return db except Exception as error: logger.error(f"Database connection failed at {host}: {error}") raise Exception("Both primary and failover database connections failed.") db = create_db_session() migrate = Migrate(app, db) # Flask-Login configuration login_manager = LoginManager() login_manager.init_app(app) login_manager.login_view = 'login' # Mastodon instance URL and access token api_base_url = 'https://chatwithus.live' access_token = os.getenv('MASTODON_ACCESS_TOKEN') if not access_token: raise ValueError("Please set the MASTODON_ACCESS_TOKEN environment variable.") else: logger.info(f"Using Mastodon access token: {access_token[:6]}...") # Initialize Mastodon API mastodon = Mastodon( access_token=access_token, api_base_url=api_base_url ) @login_manager.user_loader def load_user(user_id): with app.app_context(): session = db.session logger.debug(f"Loading user with ID: {user_id}") return session.get(User, user_id) def post_toot(toot): try: if toot.suspended: logger.info(f"Toot '{toot.message}' is suspended. Skipping post.") return logger.info(f"Attempting to post toot: {toot.message}") mastodon.status_post(toot.message) logger.info(f"Successfully posted toot: {toot.message}") except Exception as e: logger.error(f"Failed to post toot: {toot.message} due to {e}") @app.route('/') @login_required def index(): logger.debug("Rendering index page") toots = Toot.query.all() logger.debug(f"Retrieved {len(toots)} toots from the database") return render_template('index.html', toots=toots) @app.route('/add', methods=['POST']) @login_required def add_toot(): message = request.form['message'] toot_time = request.form['toot_time'] day = request.form['day'].lower() logger.debug(f"Adding new toot with message: {message}, time: {toot_time}, day: {day}") new_toot = Toot( id=str(uuid.uuid4()), message=message, toot_time=toot_time, day=day ) db.session.add(new_toot) db.session.commit() schedule_toot(new_toot) return redirect(url_for('index')) @app.route('/delete/', methods=['POST']) @login_required def delete_toot(toot_id): logger.debug(f"Deleting toot with ID: {toot_id}") toot = Toot.query.get(toot_id) if toot: db.session.delete(toot) db.session.commit() sch.clear(toot_id) logger.info(f"Deleted toot with ID: {toot_id}") else: logger.warning(f"Toot with ID {toot_id} not found") return redirect(url_for('index')) @app.route('/suspend/', methods=['POST']) @login_required def suspend_toot(toot_id): logger.debug(f"Suspending toot with ID: {toot_id}") toot = Toot.query.get(toot_id) if toot: toot.suspended = True db.session.commit() sch.clear(toot_id) # Remove scheduled job flash(f"Toot '{toot.message}' has been suspended.") logger.info(f"Suspended toot with ID: {toot_id}") else: flash("Toot not found.") logger.warning(f"Toot with ID {toot_id} not found") return redirect(url_for('index')) @app.route('/resume/', methods=['POST']) @login_required def resume_toot(toot_id): logger.debug(f"Resuming toot with ID: {toot_id}") toot = Toot.query.get(toot_id) if toot and toot.suspended: toot.suspended = False db.session.commit() schedule_toot(toot) # Reschedule the toot flash(f"Toot '{toot.message}' has been resumed.") logger.info(f"Resumed toot with ID: {toot_id}") else: flash("Toot not found or already active.") logger.warning(f"Toot with ID {toot_id} not found or not suspended") return redirect(url_for('index')) @app.route('/logout', methods=['POST']) @login_required def logout(): logger.debug("Logging out user") logout_user() return redirect(url_for('login')) @app.route('/login', methods=['GET', 'POST']) def login(): logger.debug("Rendering login page") form = LoginForm() logger.debug(f"CSRF token: {form.csrf_token.data}") if form.validate_on_submit(): logger.debug(f"Login form submitted with username: {form.username.data}") user = User.query.filter_by(username=form.username.data).first() if user and user.check_password(form.password.data): logger.info(f"User {form.username.data} authenticated successfully") login_user(user) return redirect(url_for('index')) logger.warning(f"Authentication failed for user {form.username.data}") flash('Invalid username or password') return render_template('login.html', form=form) scheduler_lock = threading.Lock() def schedule_toot(toot): try: if toot.suspended: logger.info(f"Toot '{toot.message}' is suspended. Skipping scheduling.") return with scheduler_lock: sch.clear(toot.id) day_schedule = { 'monday': sch.every().monday, 'tuesday': sch.every().tuesday, 'wednesday': sch.every().wednesday, 'thursday': sch.every().thursday, 'friday': sch.every().friday, 'saturday': sch.every().saturday, 'sunday': sch.every().sunday, 'everyday': sch.every().day } if toot.day in day_schedule: logger.info(f"Scheduling toot: {toot.message} for {toot.day} at {toot.toot_time}") day_schedule[toot.day].at(toot.toot_time).do(post_toot, toot).tag(toot.id) else: logger.error(f"Unknown day: {toot.day}. Unable to schedule toot.") except Exception as e: logger.error(f"Error scheduling toot: {str(e)}") def run_scheduler(): try: while True: sch.run_pending() t.sleep(1) except Exception as e: logger.error(f"Scheduler error: {str(e)}") def initialize_scheduler(): with app.app_context(): db.create_all() # Schedule existing toots when the app starts sch.clear() # Clear all scheduled tasks initially for toot in Toot.query.all(): schedule_toot(toot) if __name__ == '__main__': # Initialize the scheduler once, outside the debug mode restart if os.getenv("FLASK_ENV") != "development" or os.environ.get("WERKZEUG_RUN_MAIN") == "true": initialize_scheduler() # Run the scheduler in a separate thread scheduler_thread = threading.Thread(target=run_scheduler, daemon=True) scheduler_thread.start() app.run(debug=False, host='0.0.0.0', port=5010)