diff options
author | doc <doc@filenotfound.org> | 2025-06-28 21:03:28 +0000 |
---|---|---|
committer | doc <doc@filenotfound.org> | 2025-06-28 21:03:28 +0000 |
commit | 86ee174c9d81c0ed5672113fcd8e76cf30c671ec (patch) | |
tree | 78336d6aee604dad9d385b275fff7016699bd33b /app.py |
Diffstat (limited to 'app.py')
-rw-r--r-- | app.py | 287 |
1 files changed, 287 insertions, 0 deletions
@@ -0,0 +1,287 @@ +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 env from /etc/radiotoot.env unless overridden +env_path = os.getenv("ENV_PATH", "/etc/radiotoot.env") +load_dotenv(dotenv_path=env_path) + +# Environment validation +def validate_env(): + required_vars = { + "SECRET_KEY": "used to secure session cookies and forms", + "DATABASE_URL": "PostgreSQL connection string", + "MASTODON_ACCESS_TOKEN": "Token for posting to Mastodon" + } + missing = [] + for var, reason in required_vars.items(): + if not os.getenv(var): + logging.error(f"Missing required environment variable: {var} — {reason}") + missing.append(var) + if missing: + raise RuntimeError(f"Missing environment variables: {', '.join(missing)}") + +validate_env() + +# Initialize logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +# Securely configure the app secret key +app.secret_key = os.getenv("SECRET_KEY") + +# 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 +DATABASE_URL = os.getenv("DATABASE_URL") +logger.debug(f"Using database: {DATABASE_URL}") + +def create_db_session(): + app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + try: + db.init_app(app) + conn = psycopg2.connect(DATABASE_URL) + conn.close() + return db + except Exception as error: + logger.error(f"Database connection failed at {DATABASE_URL}: {error}") + raise + +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') +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/<toot_id>', 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/<toot_id>', 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) + 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/<toot_id>', 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) + 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) + +@app.route('/register', methods=['GET', 'POST']) +def register(): + form = RegistrationForm() + if form.validate_on_submit(): + username = form.username.data + email = form.email.data + password = form.password.data + hashed_password = generate_password_hash(password) + + new_user = User( + username=username, + email=email, + password=hashed_password + ) + + db.session.add(new_user) + db.session.commit() + + flash('Your account has been created! You can now log in.', 'success') + return redirect(url_for('login')) + + return render_template('register.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() + sch.clear() + for toot in Toot.query.all(): + schedule_toot(toot) + +if __name__ == '__main__': + if os.getenv("FLASK_ENV") != "development" or os.environ.get("WERKZEUG_RUN_MAIN") == "true": + initialize_scheduler() + scheduler_thread = threading.Thread(target=run_scheduler, daemon=True) + scheduler_thread.start() + + app.run(debug=False, host='0.0.0.0', port=5010) |