reworked structure and improved performance to handle 500 concurrent users

This commit is contained in:
2026-02-20 18:31:16 +01:00
parent 7226c0c698
commit f192355ec6
21 changed files with 1535 additions and 386 deletions

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
from . import routes

View File

@@ -0,0 +1,197 @@
from typing import cast, List
from flask import render_template, current_app, redirect, url_for, request, abort, session, flash
from app.utils import get_active_banner, get_course, generate_password, get_available_decks, get_passwords, get_all_courses, is_safe_url, get_token, create_token, retrieve_token, set_cookie
from app.models import db, InfoBanner, Password, Deck
from app.forms import InfoBannerForm, DeckForm, PasswordForm, LoginForm
from app.auth import user_loader, check_password
from datetime import datetime
import pytz
from . import admin_bp
import flask_login
@admin_bp.route("/login", methods=["POST", "GET"])
def admin_login():
form = LoginForm()
if form.validate_on_submit():
assert isinstance(form.user_name.data, str)
assert isinstance(form.password.data, str)
user_name: str = form.user_name.data
password: str = form.password.data
user = user_loader(user_name)
if user and check_password(user, password):
flask_login.login_user(user)
current_app.logger.info(f"Admin login successful: {user_name}")
next_page = request.args.get("next")
if next_page and not is_safe_url(next_page):
current_app.logger.warning(
f"Blocked unsafe redirect attempt to: {next_page}"
)
return abort(400)
if next_page:
session["next_url"] = next_page
if not get_token():
token = retrieve_token(user_name)
if not token:
token = create_token(user_name)
return set_cookie(token)
return redirect(next_page or url_for("admin.admin"))
else:
current_app.logger.warning(f"Failed admin login attempt: {user_name}")
flash("Incorrect username or password. Please try again.", "error")
return render_template("admin_login.html", form=form)
@admin_bp.route("/", methods=["GET", "POST"])
@flask_login.login_required
def admin():
active_course = get_course(flask_login.current_user.active_course_id)
if active_course is None:
current_app.logger.error(
f"Admin user {flask_login.current_user.id} has no active_course_id."
)
raise Exception
deck_form = DeckForm()
password_form = PasswordForm()
if request.method == "GET":
password_form.value.data = generate_password()
deck_form.deck.choices = get_available_decks(active_course)
decks = active_course.decks
password_form.decks.choices = [(deck.id, deck.name) for deck in decks]
if request.method == "POST":
if "submit_deck" in request.form and deck_form.validate():
deck = Deck(
name=cast(str, deck_form.name.data),
deck=deck_form.deck.data,
course=active_course,
description=cast(str, deck_form.description.data),
)
db.session.add(deck)
db.session.commit()
current_app.logger.info(f"Admin created deck: {deck.name}")
if "submit_password" in request.form:
if password_form.validate():
value: str = password_form.value.data
expires_at: datetime | None = password_form.expires_at.data
if expires_at:
tz = pytz.timezone("Europe/Berlin")
utc = pytz.utc
expires_at = tz.localize(expires_at).astimezone(utc)
password = Password(value=value, expires_at=expires_at)
decks = db.session.execute(
db.select(Deck).filter(
Deck.id.in_(cast(List[Deck], password_form.decks.data))
)
).scalars()
password.decks.extend(decks)
db.session.add(password)
db.session.commit()
current_app.logger.info(
f"Admin created password: {value} (Expires: {expires_at})"
)
else:
# Form was submitted but validation failed
for field, errors in password_form.errors.items():
for error in errors:
current_app.logger.warning(
f"Admin form validation error ({field}): {error}"
)
return redirect(url_for("admin.admin"))
return render_template(
"admin.html",
deck_form=deck_form,
password_form=password_form,
decks=decks,
passwords=get_passwords(active_course),
courses=get_all_courses(),
active_course_name=active_course.name,
)
@admin_bp.route("/switch_course/<new_course>", methods=["POST"])
@flask_login.login_required
def switch_course_admin(new_course: int):
user = flask_login.current_user
if get_course(new_course):
user.active_course_id = new_course
current_app.logger.info(f"Admin {user.id} switched to course ID {new_course}")
return redirect(url_for("admin.admin"))
@admin_bp.route("/banner", methods=["POST", "GET"])
@flask_login.login_required
def banner():
banner_form = InfoBannerForm()
if banner_form.validate_on_submit():
assert isinstance(banner_form.content.data, str)
content: str = banner_form.content.data
banner = InfoBanner(content=content)
db.session.add(banner)
db.session.commit()
current_app.logger.info(f"Admin added banner: {content[:30]}...")
return render_template(
"banner.html",
banner_form=banner_form,
banners=db.session.execute(db.select(InfoBanner)).scalars(),
)
@admin_bp.route("/decks/<int:deck_id>/delete", methods=["POST"])
@flask_login.login_required
def delete_deck(deck_id: int):
deck = Deck.query.get_or_404(deck_id)
current_app.logger.info(f"Admin deleted deck: {deck.name}")
db.session.delete(deck)
db.session.commit()
return redirect(url_for("admin.admin"))
@admin_bp.route("/passwords/<int:password_id>/delete", methods=["POST"])
@flask_login.login_required
def delete_password(password_id: int):
password = Password.query.get_or_404(password_id)
current_app.logger.info(f"Admin deleted password ID {password_id}")
db.session.delete(password)
db.session.commit()
return redirect(url_for("admin.admin"))
@admin_bp.route("/banner/show/<int:banner_id>", methods=["POST"])
@flask_login.login_required
def set_active_banner(banner_id):
banner = InfoBanner.query.get(banner_id)
if banner:
# ensures that only one banner is active at all times
active_banner = get_active_banner()
if active_banner:
active_banner.is_active = False
banner.is_active = True
db.session.commit()
current_app.logger.info(f"Admin activated banner ID {banner_id}")
return redirect(url_for("admin.banner"))
@admin_bp.route("/banner/hide", methods=["POST"])
@flask_login.login_required
def hide_active_banner():
banner = get_active_banner()
if banner:
banner.is_active = False
db.session.commit()
current_app.logger.info("Admin hid active banner")
return redirect(url_for("admin.banner"))
@admin_bp.route("/confused")
@flask_login.login_required
def confused_notifications():
return render_template("confused.html")

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
grafana_bp = Blueprint('grafana', __name__, url_prefix='/grafana')
from . import routes

View File

@@ -0,0 +1,5 @@
from . import grafana_bp
@grafana_bp.route('/health')
def health():
return {"status": "ok"}, 200

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
slides_bp = Blueprint('slides', __name__, url_prefix='/slides')
from . import routes # import routes after blueprint is created

View File

@@ -0,0 +1,158 @@
from . import slides_bp
from flask import current_app, abort, redirect, url_for, request, send_from_directory
from app.utils import get_cached_deck_info, token_required
from app.models import Deck, db, Course
import os
import flask_login
import re
slidev_deck_pattern = re.compile(
r"^\/slides\/[^\/]+\/[^\/]+\/(?:\d+|index\.html)(?:\?.*)?$"
)
slidev_all_pattern = re.compile(
r"^\/slides\/([^\/]+)\/([^\/]+)(?:\/presenter)?\/(\d+)(?:\?.*)?$"
)
def log_404(reason: str, deck: str, course: str, path: str, token):
"""
Helper function to log 404 errors with the user's identity.
Uses current_app.logger to ensure logs are handled by the app's config.
"""
user_identity = f"User: {token.name} (ID: {token.id})"
current_app.logger.warning(
f"404 Not Found [{reason}] | {user_identity} | Resource: {course}/{deck}/{path}"
)
@slides_bp.after_request
def inject_confused_button(response):
if (
(slidev_deck_pattern.match(request.path) or request.path == "/")
and response.content_type.startswith("text/html")
and not flask_login.current_user.is_authenticated
):
if response.direct_passthrough:
response.direct_passthrough = False
body = response.get_data(as_text=True)
insert_at = body.find("</body>")
if insert_at != -1:
button_html = """
<button id="confused-button" style="
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
background-color: #4f8cff;
color: white;
font-size: 1rem;
font-weight: bold;
border: none;
border-radius: 9999px;
padding: 1rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;"
title="Didn't understand? Click!">
Ich habe das nicht verstanden
</button>
<script>
document.getElementById('confused-button').addEventListener('click', () => {
fetch('/confused', {method: 'POST'})
.then(response => {
if (response.ok) {
alert('Alles klar! Ich versuche es besser zu erklären. Melde dich, wenn du eine spezifische Frage hast oder noch etwas unklar bleibt.');
}
}).catch(error => console.error('Error:', error));
});
</script>
"""
body = body[:insert_at] + button_html + body[insert_at:]
response.set_data(body)
return response
@slides_bp.route("/<course_folder>/<deck>/")
@slides_bp.route("/<course_folder>/<deck>/<path:path>")
@token_required
def serve_deck(course_folder, deck, token, path="index.html"):
# 1. Ask the cache for the deck data (0 DB queries on a cache hit!)
deck_info = get_cached_deck_info(token.id, course_folder, deck)
# 2. Handle missing data
if not deck_info["course_exists"]:
log_404("Course not found", deck, course_folder, path, token)
abort(404)
if not deck_info["deck_exists"]:
log_404("Deck not in database", deck, course_folder, path, token)
abort(404)
# 3. Handle authorization
if not deck_info["auth"]:
current_app.logger.warning(
f"Access denied (locked) for user {token.name} -> {deck_info.get('deck_name', deck)}"
)
return redirect(url_for("main.index"))
# 4. Resolve the file path entirely using strings
slides_dir = os.path.join(current_app.config["SLIDES_DIR"], course_folder)
deck_path = os.path.join(slides_dir, deck)
if not os.path.exists(deck_path):
log_404("Deck directory missing on disk", deck, course_folder, path, token)
return f"Deck '{deck_info['deck_name']}' not found.", 404
# 5. Serve the file
if slidev_all_pattern.match(request.path):
current_app.logger.info(f"User reloaded in deck {deck_info['deck_name']}")
return send_from_directory(deck_path, deck_info["index_file"])
requested_file = os.path.join(deck_path, path)
if os.path.isfile(requested_file):
if path == deck_info["index_file"]:
current_app.logger.info(
f"User {token.name} accessed deck {deck_info['deck_name']}"
)
return send_from_directory(deck_path, path)
log_404("File missing within deck", deck, course_folder, path, token)
abort(404)
@slides_bp.route("/<course_folder>/<deck>/export")
@token_required
def serve_export(deck, course_folder, token):
course = db.session.execute(
db.select(Course).where(Course.folder == course_folder)
).scalar()
if not course:
log_404("Export: Course not found", deck, course_folder, "", token)
abort(404)
assert isinstance(course, Course)
deck_obj = db.session.execute(
db.select(Deck).where(Deck.deck == deck, Deck.course_id == course.id)
).scalar()
if not deck_obj:
log_404("Export: Deck not in database", deck, course_folder, "", token)
abort(404)
assert isinstance(deck_obj, Deck)
if not deck_obj.export_file:
log_404("Export: Deck has no export_file set", deck, course_folder, "", token)
abort(404)
if not token.is_unlocked(deck_obj.deck):
return redirect(url_for("main.index"))
exports_dir = os.path.join(current_app.config["EXPORTS_DIR"], course.folder)
deck_path = os.path.join(exports_dir, deck_obj.deck)
requested_file = os.path.join(deck_path, deck_obj.export_file)
if os.path.isfile(requested_file):
current_app.logger.info(f"User {token.name} exported deck {deck_obj.name}")
return send_from_directory(deck_path, deck_obj.export_file)
log_404("Export file missing", deck, course_folder, deck_obj.export_file, token)
abort(404)