reworked structure and improved performance to handle 500 concurrent users
This commit is contained in:
@@ -38,4 +38,4 @@ COPY --from=css-builder /build/static/css/output.css ./app/static/css/output.css
|
|||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Gunicorn command
|
# Gunicorn command
|
||||||
CMD ["gunicorn", "--preload", "-w", "4", "-b", "0.0.0.0:5000", "app:create_app()"]
|
CMD ["gunicorn", "-w", "4", "--worker-class", "eventlet", "-b", "0.0.0.0:5000", "app.wsgi:app"]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from app.auth import init_auth
|
|||||||
from app.seed_db import seed_courses
|
from app.seed_db import seed_courses
|
||||||
from app.config import Config
|
from app.config import Config
|
||||||
|
|
||||||
socketio = SocketIO(async_mode='eventlet', cors_allowed_origins="*")
|
socketio = SocketIO(async_mode='eventlet', cors_allowed_origins="*", message_queue='redis://redis:6379')
|
||||||
|
|
||||||
def create_app(config_class=Config):
|
def create_app(config_class=Config):
|
||||||
# 1. Setup Log Directory
|
# 1. Setup Log Directory
|
||||||
@@ -42,5 +42,13 @@ def create_app(config_class=Config):
|
|||||||
socketio.init_app(app)
|
socketio.init_app(app)
|
||||||
|
|
||||||
from .routes import main
|
from .routes import main
|
||||||
|
from .blueprints.slides import slides_bp
|
||||||
|
from .blueprints.admin import admin_bp
|
||||||
|
from .blueprints.grafana import grafana_bp
|
||||||
|
|
||||||
app.register_blueprint(main)
|
app.register_blueprint(main)
|
||||||
|
app.register_blueprint(slides_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(grafana_bp)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ users: Dict[str, User]
|
|||||||
def init_auth(app: Flask):
|
def init_auth(app: Flask):
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
bcrypt.init_app(app)
|
bcrypt.init_app(app)
|
||||||
login_manager.login_view = "main.admin_login" # type: ignore
|
login_manager.login_view = "admin.admin_login" # type: ignore
|
||||||
global users
|
global users
|
||||||
users = {"cato447": User("cato447", app.config["ADMIN_PASSWORD"], app.config["DEFAULT_COURSE"])}
|
users = {"cato447": User("cato447", app.config["ADMIN_PASSWORD"], app.config["DEFAULT_COURSE"])}
|
||||||
class User(flask_login.UserMixin):
|
class User(flask_login.UserMixin):
|
||||||
|
|||||||
5
app/blueprints/admin/__init__.py
Normal file
5
app/blueprints/admin/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
|
|
||||||
|
from . import routes
|
||||||
197
app/blueprints/admin/routes.py
Normal file
197
app/blueprints/admin/routes.py
Normal 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")
|
||||||
5
app/blueprints/grafana/__init__.py
Normal file
5
app/blueprints/grafana/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
grafana_bp = Blueprint('grafana', __name__, url_prefix='/grafana')
|
||||||
|
|
||||||
|
from . import routes
|
||||||
5
app/blueprints/grafana/routes.py
Normal file
5
app/blueprints/grafana/routes.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from . import grafana_bp
|
||||||
|
|
||||||
|
@grafana_bp.route('/health')
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}, 200
|
||||||
5
app/blueprints/slides/__init__.py
Normal file
5
app/blueprints/slides/__init__.py
Normal 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
|
||||||
158
app/blueprints/slides/routes.py
Normal file
158
app/blueprints/slides/routes.py
Normal 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)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# This file was autogenerated by uv via the following command:
|
# This file was autogenerated by uv via the following command:
|
||||||
# uv export --format requirements-txt -o requirements.txt --no-hashes
|
# uv export --format requirements-txt -o requirements.txt --no-hashes
|
||||||
|
async-timeout==5.0.1 ; python_full_version < '3.11.3'
|
||||||
|
# via redis
|
||||||
bcrypt==5.0.0
|
bcrypt==5.0.0
|
||||||
# via flask-bcrypt
|
# via flask-bcrypt
|
||||||
bidict==0.23.1
|
bidict==0.23.1
|
||||||
@@ -68,6 +70,10 @@ python-socketio==5.13.0
|
|||||||
# via flask-socketio
|
# via flask-socketio
|
||||||
pytz==2025.2
|
pytz==2025.2
|
||||||
# via tutor-tool
|
# via tutor-tool
|
||||||
|
redis==7.0.1 ; python_full_version < '3.10'
|
||||||
|
# via tutor-tool
|
||||||
|
redis==7.2.0 ; python_full_version >= '3.10'
|
||||||
|
# via tutor-tool
|
||||||
simple-websocket==1.1.0
|
simple-websocket==1.1.0
|
||||||
# via python-engineio
|
# via python-engineio
|
||||||
sqlalchemy==2.0.43
|
sqlalchemy==2.0.43
|
||||||
|
|||||||
370
app/routes.py
370
app/routes.py
@@ -1,57 +1,38 @@
|
|||||||
import os
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, cast
|
|
||||||
|
|
||||||
import flask_login
|
import flask_login
|
||||||
from flask_socketio import join_room
|
from flask_socketio import join_room
|
||||||
|
|
||||||
import pytz
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
abort,
|
|
||||||
current_app, # Used for logging
|
current_app, # Used for logging
|
||||||
flash,
|
flash,
|
||||||
make_response,
|
make_response,
|
||||||
redirect,
|
redirect,
|
||||||
render_template,
|
render_template,
|
||||||
request,
|
request,
|
||||||
send_from_directory,
|
|
||||||
session,
|
|
||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import socketio
|
from . import socketio
|
||||||
|
|
||||||
from .auth import check_password, user_loader
|
from .auth import user_loader
|
||||||
from .forms import DeckForm, InfoBannerForm, LoginForm, PasswordForm
|
from .models import Password, db
|
||||||
from .models import Course, Deck, InfoBanner, Password, db
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
create_token,
|
create_token,
|
||||||
generate_password,
|
|
||||||
get_active_banner,
|
get_active_banner,
|
||||||
get_all_courses,
|
get_all_courses,
|
||||||
get_available_decks,
|
|
||||||
get_passwords,
|
|
||||||
get_token,
|
get_token,
|
||||||
get_unlocked_decks,
|
get_unlocked_decks,
|
||||||
retrieve_token,
|
retrieve_token,
|
||||||
serialize_token,
|
|
||||||
update_token,
|
update_token,
|
||||||
get_course,
|
get_course,
|
||||||
token_required,
|
token_required,
|
||||||
get_cached_deck_info,
|
set_cookie,
|
||||||
is_safe_url,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
main = Blueprint("main", __name__)
|
main = Blueprint("main", __name__)
|
||||||
slidev_deck_pattern = re.compile(
|
|
||||||
r"^\/slides\/[^\/]+\/[^\/]+\/(?:\d+|index\.html)(?:\?.*)?$"
|
|
||||||
)
|
|
||||||
slidev_all_pattern = re.compile(
|
|
||||||
r"^\/slides\/([^\/]+)\/([^\/]+)(?:\/presenter)?\/(\d+)(?:\?.*)?$"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@socketio.on("connect")
|
@socketio.on("connect")
|
||||||
@@ -63,53 +44,6 @@ def on_connect():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@main.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
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/")
|
@main.route("/")
|
||||||
@token_required
|
@token_required
|
||||||
def index(token):
|
def index(token):
|
||||||
@@ -126,23 +60,6 @@ def index(token):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_cookie(token):
|
|
||||||
next_url = session.pop("next_url", None)
|
|
||||||
if not next_url:
|
|
||||||
current_app.logger.debug("next_url not present redirecting to index")
|
|
||||||
next_url = url_for("main.index")
|
|
||||||
|
|
||||||
if not is_safe_url(next_url):
|
|
||||||
current_app.logger.warning(f"Blocked unsafe redirect attempt to: {next_url}")
|
|
||||||
return abort(400)
|
|
||||||
|
|
||||||
resp = make_response(redirect(next_url))
|
|
||||||
resp.set_cookie(
|
|
||||||
"access_token", serialize_token(token), httponly=True, samesite="Lax", path="/", max_age=31536000
|
|
||||||
)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/set_token", methods=["POST", "GET"])
|
@main.route("/set_token", methods=["POST", "GET"])
|
||||||
def set_token():
|
def set_token():
|
||||||
"""
|
"""
|
||||||
@@ -163,7 +80,7 @@ def set_token():
|
|||||||
current_app.logger.info(
|
current_app.logger.info(
|
||||||
f"Admin username '{username}' entered in set_token. Redirecting to admin login."
|
f"Admin username '{username}' entered in set_token. Redirecting to admin login."
|
||||||
)
|
)
|
||||||
return redirect(url_for("main.admin_login"))
|
return redirect(url_for("admin.admin_login"))
|
||||||
|
|
||||||
token = retrieve_token(username)
|
token = retrieve_token(username)
|
||||||
|
|
||||||
@@ -253,44 +170,6 @@ def switch_course(new_course: int, token):
|
|||||||
return redirect(url_for("main.index"))
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
|
||||||
@main.route("/admin/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("main.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)
|
|
||||||
|
|
||||||
|
|
||||||
def process_access_code(access_code: str, token):
|
def process_access_code(access_code: str, token):
|
||||||
password = db.session.execute(
|
password = db.session.execute(
|
||||||
db.select(Password).where(Password.value == access_code)
|
db.select(Password).where(Password.value == access_code)
|
||||||
@@ -333,244 +212,3 @@ def access_get(access_code: str, token):
|
|||||||
def confused():
|
def confused():
|
||||||
socketio.emit("confused", {"time": datetime.utcnow().isoformat()}, to="instructor")
|
socketio.emit("confused", {"time": datetime.utcnow().isoformat()}, to="instructor")
|
||||||
return ("", 204)
|
return ("", 204)
|
||||||
|
|
||||||
|
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/slides/<course_folder>/<deck>/")
|
|
||||||
@main.route("/slides/<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)
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/exports/<course_folder>/<deck>/<path>")
|
|
||||||
@token_required
|
|
||||||
def serve_export(deck, course_folder, path, 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, path, 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, path, token)
|
|
||||||
abort(404)
|
|
||||||
assert isinstance(deck_obj, Deck)
|
|
||||||
|
|
||||||
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, path)
|
|
||||||
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, path)
|
|
||||||
|
|
||||||
log_404("Export file missing", deck, course_folder, path, token)
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/admin", 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("main.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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/admin/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("main.admin"))
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/admin/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(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/admin/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("main.admin"))
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/admin/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("main.admin"))
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/admin/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("main.banner"))
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/admin/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("main.banner"))
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/admin/confused")
|
|
||||||
@flask_login.login_required
|
|
||||||
def confused_notifications():
|
|
||||||
return render_template("confused.html")
|
|
||||||
|
|||||||
@@ -44,13 +44,13 @@
|
|||||||
<div class="w-auto" id="navbar-default">
|
<div class="w-auto" id="navbar-default">
|
||||||
<ul class="flex flex-row space-x-8 rtl:space-x-reverse font-medium p-0 border-0 bg-white dark:bg-gray-900">
|
<ul class="flex flex-row space-x-8 rtl:space-x-reverse font-medium p-0 border-0 bg-white dark:bg-gray-900">
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('main.admin') }}" class="rounded-sm text-blue-700 dark:text-blue-500 font-semibold" aria-current="page">Deck Admin</a>
|
<a href="{{ url_for('admin.admin') }}" class="rounded-sm text-blue-700 dark:text-blue-500 font-semibold" aria-current="page">Deck Admin</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('main.banner') }}" class="text-gray-900 dark:text-white hover:text-blue-700 dark:hover:text-blue-500">Banner</a>
|
<a href="{{ url_for('admin.banner') }}" class="text-gray-900 dark:text-white hover:text-blue-700 dark:hover:text-blue-500">Banner</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('main.confused_notifications') }}" class="text-gray-900 dark:text-white hover:text-blue-700 dark:hover:text-blue-500">Confused</a>
|
<a href="{{ url_for('admin.confused_notifications') }}" class="text-gray-900 dark:text-white hover:text-blue-700 dark:hover:text-blue-500">Confused</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2">
|
||||||
<form method="POST" action="{{ url_for('main.delete_deck', deck_id=deck.id) }}">
|
<form method="POST" action="{{ url_for('admin.delete_deck', deck_id=deck.id) }}">
|
||||||
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-lg text-sm">Delete</button>
|
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-lg text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2">
|
||||||
<form method="POST" action="{{ url_for('main.delete_password', password_id=password.id) }}">
|
<form method="POST" action="{{ url_for('admin.delete_password', password_id=password.id) }}">
|
||||||
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-lg text-sm">Delete</button>
|
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-lg text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -21,20 +21,20 @@
|
|||||||
<div class="hidden w-full md:block md:w-auto" id="navbar-default">
|
<div class="hidden w-full md:block md:w-auto" id="navbar-default">
|
||||||
<ul class="font-medium flex flex-col justify-between md:justify-evenly p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:flex-row md:space-x-8 rtl:space-x-reverse md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
|
<ul class="font-medium flex flex-col justify-between md:justify-evenly p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:flex-row md:space-x-8 rtl:space-x-reverse md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
|
||||||
<li>
|
<li>
|
||||||
<a href="{{url_for('main.admin')}}"
|
<a href="{{url_for('admin.admin')}}"
|
||||||
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">
|
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">
|
||||||
Deck Admin
|
Deck Admin
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{url_for('main.banner')}}"
|
<a href="{{url_for('admin.banner')}}"
|
||||||
class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 dark:text-white md:dark:text-blue-500"
|
class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 dark:text-white md:dark:text-blue-500"
|
||||||
aria-current="page">
|
aria-current="page">
|
||||||
Banner
|
Banner
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{url_for('main.confused_notifications')}}"
|
<a href="{{url_for('admin.confused_notifications')}}"
|
||||||
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">
|
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">
|
||||||
Confused
|
Confused
|
||||||
</a>
|
</a>
|
||||||
@@ -80,11 +80,11 @@
|
|||||||
<div class="block bg-surface border border-border rounded-xl p-5 shadow-sm hover:border-primary hover:bg-background transition">
|
<div class="block bg-surface border border-border rounded-xl p-5 shadow-sm hover:border-primary hover:bg-background transition">
|
||||||
<h3 class="text-lg font-semibold mb-1">{{ banner.content }}</h3>
|
<h3 class="text-lg font-semibold mb-1">{{ banner.content }}</h3>
|
||||||
{% if banner.is_active %}
|
{% if banner.is_active %}
|
||||||
<form method="POST" action="{{ url_for('main.hide_active_banner') }}">
|
<form method="POST" action="{{ url_for('admin.hide_active_banner') }}">
|
||||||
<button type="submit" class="w-full bg-red-600 hover:bg-red-700 text-white py-2 rounded-md transition">Hide</button>
|
<button type="submit" class="w-full bg-red-600 hover:bg-red-700 text-white py-2 rounded-md transition">Hide</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="POST", action="{{ url_for('main.set_active_banner', banner_id=banner.id)}}">
|
<form method="POST", action="{{ url_for('admin.set_active_banner', banner_id=banner.id)}}">
|
||||||
<button type="submit" class="w-full bg-primary hover:bg-blue-700 text-white py-2 rounded-md transition">
|
<button type="submit" class="w-full bg-primary hover:bg-blue-700 text-white py-2 rounded-md transition">
|
||||||
Show banner
|
Show banner
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,19 +16,19 @@
|
|||||||
<div class="hidden w-full md:block md:w-auto" id="navbar-default">
|
<div class="hidden w-full md:block md:w-auto" id="navbar-default">
|
||||||
<ul class="font-medium flex flex-col justify-between md:justify-evenly p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:flex-row md:space-x-8 rtl:space-x-reverse md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
|
<ul class="font-medium flex flex-col justify-between md:justify-evenly p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:flex-row md:space-x-8 rtl:space-x-reverse md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
|
||||||
<li>
|
<li>
|
||||||
<a href="{{url_for('main.admin')}}"
|
<a href="{{url_for('admin.admin')}}"
|
||||||
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">
|
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">
|
||||||
Deck Admin
|
Deck Admin
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{url_for('main.banner')}}"
|
<a href="{{url_for('admin.banner')}}"
|
||||||
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">
|
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">
|
||||||
Banner
|
Banner
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{url_for('main.confused_notifications')}}"
|
<a href="{{url_for('admin.confused_notifications')}}"
|
||||||
class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 dark:text-white md:dark:text-blue-500"
|
class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 dark:text-white md:dark:text-blue-500"
|
||||||
aria-current="page">
|
aria-current="page">
|
||||||
Confused
|
Confused
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
<!-- Socket.IO client (served by your Flask-SocketIO) -->
|
<!-- Socket.IO client (served by your Flask-SocketIO) -->
|
||||||
<script>
|
<script>
|
||||||
const notifList = document.getElementById("notif-list");
|
const notifList = document.getElementById("notif-list");
|
||||||
const socket = io();
|
const socket = io({ transports: ['websocket'] });
|
||||||
|
|
||||||
socket.on("confused", data => {
|
socket.on("confused", data => {
|
||||||
// Remove placeholder if it’s still there
|
// Remove placeholder if it’s still there
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<img src="{{ url_for('static', filename='discord_icon.svg') }}" class="h-8 w-8">
|
<img src="{{ url_for('static', filename='discord_icon.svg') }}" class="h-8 w-8">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="/admin">
|
<a href="{{ url_for('admin.admin') }}">
|
||||||
<button class="bg-primary hover:bg-blue-700 text-white px-3 py-1 rounded-md transition">
|
<button class="bg-primary hover:bg-blue-700 text-white px-3 py-1 rounded-md transition">
|
||||||
Admin
|
Admin
|
||||||
</button>
|
</button>
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
<p class="text-muted text-sm">{{ deck.description }}</p>
|
<p class="text-muted text-sm">{{ deck.description }}</p>
|
||||||
</a>
|
</a>
|
||||||
{% if deck.export_file %}
|
{% if deck.export_file %}
|
||||||
<a href="/exports/{{deck.course.folder}}/{{ deck.deck }}/{{ deck.export_file}}"
|
<a href="/slides/{{deck.course.folder}}/{{ deck.deck }}/export"
|
||||||
class="inline-flex items-center justify-center flex-shrink-0 h-10 w-10 text-on-primary hover:bg-primary/90 transition"
|
class="inline-flex items-center justify-center flex-shrink-0 h-10 w-10 text-on-primary hover:bg-primary/90 transition"
|
||||||
download title="Download Presentation">
|
download title="Download Presentation">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
|||||||
20
app/utils.py
20
app/utils.py
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import request, session, redirect, url_for, current_app
|
from flask import request, session, redirect, url_for, current_app, abort, make_response
|
||||||
from typing import cast
|
from typing import cast
|
||||||
from itsdangerous import BadSignature, URLSafeSerializer
|
from itsdangerous import BadSignature, URLSafeSerializer
|
||||||
from xkcdpass import xkcd_password
|
from xkcdpass import xkcd_password
|
||||||
@@ -10,6 +10,7 @@ from urllib.parse import urlparse, urljoin
|
|||||||
|
|
||||||
from .models import Deck, InfoBanner, Token, db, Course
|
from .models import Deck, InfoBanner, Token, db, Course
|
||||||
|
|
||||||
|
|
||||||
# Create a module-level logger per Flask/Python standards
|
# Create a module-level logger per Flask/Python standards
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -96,6 +97,23 @@ def create_token(user_name: str) -> Token:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def set_cookie(token):
|
||||||
|
next_url = session.pop("next_url", None)
|
||||||
|
if not next_url:
|
||||||
|
current_app.logger.debug("next_url not present redirecting to index")
|
||||||
|
next_url = url_for("main.index")
|
||||||
|
|
||||||
|
if not is_safe_url(next_url):
|
||||||
|
current_app.logger.warning(f"Blocked unsafe redirect attempt to: {next_url}")
|
||||||
|
return abort(400)
|
||||||
|
|
||||||
|
resp = make_response(redirect(next_url))
|
||||||
|
resp.set_cookie(
|
||||||
|
"access_token", serialize_token(token), httponly=True, samesite="Lax", path="/", max_age=31536000
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def serialize_token(token: Token) -> str:
|
def serialize_token(token: Token) -> str:
|
||||||
return serializer.dumps(token.id)
|
return serializer.dumps(token.id)
|
||||||
|
|
||||||
|
|||||||
5
app/wsgi.py
Normal file
5
app/wsgi.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import eventlet
|
||||||
|
eventlet.monkey_patch()
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
app = create_app()
|
||||||
@@ -27,4 +27,10 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ dependencies = [
|
|||||||
"flask-sqlalchemy>=3.1.1",
|
"flask-sqlalchemy>=3.1.1",
|
||||||
"flask-wtf>=1.2.2",
|
"flask-wtf>=1.2.2",
|
||||||
"gunicorn>=23.0.0",
|
"gunicorn>=23.0.0",
|
||||||
|
"locust>=2.34.0",
|
||||||
"psycopg2-binary>=2.9.11",
|
"psycopg2-binary>=2.9.11",
|
||||||
"pytz>=2025.2",
|
"pytz>=2025.2",
|
||||||
|
"redis>=7.0.1",
|
||||||
"xkcdpass>=1.20.0",
|
"xkcdpass>=1.20.0",
|
||||||
]
|
]
|
||||||
|
|||||||
27
test/locustfile.py
Normal file
27
test/locustfile.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from locust import HttpUser, task, between
|
||||||
|
import random
|
||||||
|
|
||||||
|
class StudentUser(HttpUser):
|
||||||
|
wait_time = between(1, 10)
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
# Register a user and get a cookie
|
||||||
|
username = f"testuser_{random.randint(1, 100000)}"
|
||||||
|
self.client.post("/register", data={
|
||||||
|
"username": username,
|
||||||
|
"acknowledge": "on"
|
||||||
|
})
|
||||||
|
self.client.post("/access", data={"password": "locust"})
|
||||||
|
|
||||||
|
@task(5)
|
||||||
|
def view_index(self):
|
||||||
|
self.client.get("/")
|
||||||
|
|
||||||
|
@task(1)
|
||||||
|
def enter_password(self):
|
||||||
|
self.client.post("/access", data={"password": "locust_test"})
|
||||||
|
|
||||||
|
@task(3)
|
||||||
|
def view_slide(self):
|
||||||
|
# Simulate loading slide files
|
||||||
|
self.client.get("/slides/itsec2526/tut-01/index.html")
|
||||||
Reference in New Issue
Block a user