Ctrl K
ComunidadGuías…

mindbaboon.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# mindbaboon.py
import sqlite3
import random
import os
import logging
from datetime import datetime, timedelta

from flask import Flask, render_template, request, redirect, url_for, jsonify, session, has_request_context
from flask_wtf.csrf import CSRFProtect
from flask_talisman import Talisman
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from werkzeug.middleware.proxy_fix import ProxyFix

from database import (
    get_db_connection,
    get_setting,
    set_setting,
    get_iteration_slot,
    set_iteration_slot,
)
from iteration import iteration_bp
from api import api_bp
from auth import auth_bp, init_oauth, require_login
from scheduler import (
    scheduler,
    schedule_reminder,
    remove_reminder,
    send_startup_email,
    get_next_run_for_goal,
    reschedule_all_active,
)
from config import VERSION, MOTIVATIONAL_QUOTES
from init_mindbaboon_db import initialize_database


logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

default_email = get_setting("default_email")
if default_email:
    os.environ["DEFAULT_TO_ADDRESS"] = default_email


def _require_env(name: str) -> str:
    value = os.environ.get(name, "").strip()
    if not value or value.startswith(("dev-", "changeme")):
        raise RuntimeError(f"{name} must be set to a strong production value")
    return value


app = Flask(__name__)

# Cloudflare Tunnel terminates TLS and forwards via HTTP — without ProxyFix
# Flask sees scheme=http and url_for(_external=True) generates http:// URLs,
# which then mismatch the OAuth redirect_uri registered in GCP.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)

app.secret_key = _require_env("FLASK_SECRET_KEY")
app.config.update(
    SESSION_COOKIE_NAME="__Host-mb_session",  # __Host- prefix requires Secure+Path=/+no Domain
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
    PERMANENT_SESSION_LIFETIME=timedelta(days=30),
    WTF_CSRF_TIME_LIMIT=None,  # tie token lifetime to session, not 1h default
)

csrf = CSRFProtect(app)
# API uses X-API-Key auth (CSRF-immune by design). Exempt the whole blueprint.
csrf.exempt(api_bp)

Talisman(
    app,
    force_https=False,  # Cloudflare handles HTTPS upgrade; ProxyFix tells us scheme
    strict_transport_security=True,
    strict_transport_security_max_age=31536000,
    strict_transport_security_include_subdomains=True,
    strict_transport_security_preload=True,
    frame_options="DENY",
    referrer_policy="strict-origin-when-cross-origin",
    content_security_policy={
        "default-src": "'self'",
        # typekit serves CSS from use.typekit.net AND p.typekit.net (sharded)
        "style-src": ["'self'", "'unsafe-inline'", "https://use.typekit.net", "https://p.typekit.net"],
        # typekit inlines woff2 as data: URIs after the stylesheet loads
        "font-src": ["'self'", "data:", "https://use.typekit.net", "https://p.typekit.net"],
        "img-src": ["'self'", "data:"],
        # All page-specific JS lives in static/*.js (page-form.js,
        # page-edit.js, page-iteration.js, page-index.js, placeholder-toggle.js).
        # Inline event handlers in templates replaced with addEventListener.
        # Jinja interpolation passes through data-* attributes.
        "script-src": "'self'",
        "form-action": ["'self'", "https://accounts.google.com"],
        "frame-ancestors": "'none'",
    },
    content_security_policy_nonce_in=None,
)

limiter = Limiter(
    key_func=get_remote_address,
    app=app,
    default_limits=[],
    storage_uri="memory://",
)

init_oauth(app)

app.register_blueprint(auth_bp)
app.register_blueprint(iteration_bp)
app.register_blueprint(api_bp)


@app.before_request
def _make_session_permanent():
    session.permanent = True


@app.context_processor
def inject_globals():
    # `session` requires a request context. Email templates render in app
    # context only (startup_email, confirmation_email, reminder), so guard
    # with has_request_context() — otherwise the app crashes at startup.
    user = session.get("user") if has_request_context() else None
    return {
        "current_year": datetime.now().year,
        "version": VERSION,
        "current_user": user,
    }


def init_scheduler():
    try:
        if not scheduler.running:
            scheduler.start()
            logger.debug("Scheduler started")
    except Exception as e:
        logger.error(f"Failed to start scheduler: {e}")
        raise


@app.route("/")
@require_login
def index():
    message = request.args.get("message")
    conn = get_db_connection()
    goals = conn.execute("""
        SELECT g.*, ih.next_run
        FROM goals g
        LEFT JOIN (
            SELECT iteration_id, MAX(next_run) as next_run
            FROM iteration_history
            GROUP BY iteration_id
        ) ih ON g.id = ih.iteration_id
    """).fetchall()
    conn.close()

    random_quote = random.choice(MOTIVATIONAL_QUOTES)
    goals = [dict(goal) for goal in goals]

    for goal in goals:
        last_reminder_at = goal.get('last_reminder_at')
        next_run = get_next_run_for_goal(goal['id'])
        if last_reminder_at and next_run:
            last_reminder = datetime.strptime(last_reminder_at, '%Y-%m-%d %H:%M:%S')
            next_run_time = datetime.strptime(next_run, '%Y-%m-%d %H:%M:%S')
            total_time = (next_run_time - last_reminder).total_seconds()
            remaining_time = (next_run_time - datetime.now()).total_seconds()
            progress = max(0, min(100, ((total_time - remaining_time) / total_time) * 100))
            goal['progress'] = progress
        else:
            goal['progress'] = 0
        goal['next_run'] = next_run

    return render_template("index.html", goals=goals, motivational_quote=random_quote, message=message, version=VERSION)


@app.route("/add", methods=["GET", "POST"])
@require_login
def add_goal():
    if request.method == "POST":
        goal_name = (request.form.get("goal_name") or "").strip()
        if not goal_name:
            return "goal_name is required", 400
        goal_description = request.form.get("goal_description", "")
        time_span = request.form.get("time_span", "")
        specific_date = request.form.get("specific_date") if time_span == "specific_date" else None
        iteration = request.form.get("iteration", "")
        next_steps = request.form.get("next_steps", "")
        reward = request.form.get("reward", "")

        now = datetime.now()

        conn = get_db_connection()
        cur = conn.cursor()
        cur.execute("""
            INSERT INTO goals (
                goal_name, goal_description, time_span, end_date,
                iteration, next_steps, reward, completed, created_at, last_reminder_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """, (goal_name, goal_description, time_span, specific_date,
              iteration, next_steps, reward, 0, now, None))
        conn.commit()
        goal_id = cur.lastrowid
        conn.close()

        schedule_reminder(goal_id, iteration)
        return redirect(url_for("index"))

    return render_template("add.html", version=VERSION)


@app.route("/edit/<int:goal_id>", methods=["GET", "POST"])
@require_login
def edit_goal(goal_id):
    conn = get_db_connection()
    goal = conn.execute("SELECT * FROM goals WHERE id = ?", (goal_id,)).fetchone()

    if not goal:
        conn.close()
        return jsonify({"error": "Goal not found"}), 404

    if request.method == "POST":
        goal_name = (request.form.get("goal_name") or "").strip()
        if not goal_name:
            conn.close()
            return "goal_name is required", 400
        goal_description = request.form.get("goal_description", "")
        time_span = request.form.get("time_span", "")
        specific_date = request.form.get("specific_date") if time_span == "specific_date" else None
        iteration = request.form.get("iteration", "")
        next_steps = request.form.get("next_steps", "")
        reward = request.form.get("reward", "")
        completed = 1 if request.form.get("completed") == "on" else 0

        conn.execute("""
            UPDATE goals
            SET goal_name = ?, goal_description = ?, time_span = ?, end_date = ?,
                iteration = ?, next_steps = ?, reward = ?, completed = ?
            WHERE id = ?
        """, (goal_name, goal_description, time_span, specific_date,
              iteration, next_steps, reward, completed, goal_id))
        conn.commit()
        conn.close()

        schedule_reminder(goal_id, iteration)
        return redirect(url_for("index"))

    conn.close()
    return render_template("edit.html", goal=goal, version=VERSION)


@app.route('/delete_goal', methods=['POST'])
@require_login
def delete_goal():
    goal_id = request.form.get('goal_id')
    if not goal_id:
        return redirect(url_for("index"))
    try:
        conn = get_db_connection()
        conn.execute("DELETE FROM goals WHERE id = ?", (goal_id,))
        conn.commit()
        conn.close()
        remove_reminder(goal_id)
    except sqlite3.Error as e:
        logger.error(f"DB error deleting goal {goal_id}: {e}")
    return redirect(url_for("index"))


WEEKDAYS = [(0, "Mon"), (1, "Tue"), (2, "Wed"), (3, "Thu"),
            (4, "Fri"), (5, "Sat"), (6, "Sun")]


@app.route("/settings", methods=["GET", "POST"], endpoint="settings")
@require_login
def settings_view():
    if request.method == "POST":
        email_address = request.form["email_address"]
        set_setting("default_email", email_address)
        os.environ["DEFAULT_TO_ADDRESS"] = email_address

        old_slot = get_iteration_slot()
        wd = int(request.form.get("iteration_weekday", old_slot["weekday"]))
        hr = int(request.form.get("iteration_hour", old_slot["hour"]))
        mn = int(request.form.get("iteration_minute", old_slot["minute"]))
        set_iteration_slot(weekday=wd, hour=hr, minute=mn)

        msg = "Settings saved."
        if (wd, hr, mn) != (old_slot["weekday"], old_slot["hour"], old_slot["minute"]):
            rescheduled = reschedule_all_active()
            msg = f"Settings saved. Rescheduled {len(rescheduled)} goal(s)."
        return redirect(url_for("index", message=msg))

    current_email = get_setting("default_email") or "example@domain.com"
    return render_template(
        "settings.html",
        current_email=current_email,
        slot=get_iteration_slot(),
        weekdays=WEEKDAYS,
    )


if __name__ == "__main__":
    import socket
    logger.info(f"STARTUP pid={os.getpid()} host={socket.gethostname()} version={VERSION}")
    initialize_database()
    init_scheduler()
    send_startup_email()
    logger.info("Starting Flask application...")
    app.run(host='0.0.0.0', port=5000)