majorly overhauled the design to now use docker-compose and postgres

This commit is contained in:
2026-02-20 02:02:23 +01:00
parent 9769f509a7
commit 7308ad541d
18 changed files with 633 additions and 256 deletions

View File

@@ -10,7 +10,7 @@ import pytz
from flask import (
Blueprint,
abort,
current_app, # Used for logging
current_app, # Used for logging
flash,
make_response,
redirect,
@@ -35,31 +35,44 @@ from .utils import (
get_passwords,
get_token,
get_unlocked_decks,
is_deck_unlocked,
retrieve_token,
serialize_token,
update_token,
get_course
get_course,
token_required,
get_cached_deck_info,
)
main = Blueprint('main', __name__)
slidev_deck_pattern = re.compile(r"^\/slides\/[^\/]+\/[^\/]+\/(?:\d+|index\.html)(?:\?.*)?$")
slidev_all_pattern = re.compile(r"^\/slides\/([^\/]+)\/([^\/]+)(?:\/presenter)?\/(\d+)(?:\?.*)?$")
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")
def on_connect():
if flask_login.current_user.is_authenticated:
join_room("instructor")
current_app.logger.info(f"Instructor connected to socket: {flask_login.current_user.id}")
current_app.logger.info(
f"Instructor connected to socket: {flask_login.current_user.id}"
)
@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 (
(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>')
insert_at = body.find("</body>")
if insert_at != -1:
button_html = """
<button id="confused-button" style="
@@ -96,129 +109,143 @@ def inject_confused_button(response):
return response
@main.route('/')
def index():
token = get_token()
if not token:
current_app.logger.debug("User has no token. Redirecting to set_token.")
return redirect(url_for("main.set_token"))
else:
current_app.logger.debug(f"User {token.name} is viewing index with {len(token.decks)} decks unlocked.")
return render_template('index.html', active_banner=get_active_banner(), decks=get_unlocked_decks(token), user_name=token.name, active_course_name=token.active_course.name, courses=get_all_courses())
@main.route("/")
@token_required
def index(token):
current_app.logger.debug(
f"User {token.name} is viewing index with {len(token.decks)} decks unlocked."
)
return render_template(
"index.html",
active_banner=get_active_banner(),
decks=get_unlocked_decks(token),
user_name=token.name,
active_course_name=token.active_course.name,
courses=get_all_courses(),
)
def set_cookie(token):
next_url = session.pop('next_url', None)
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")
resp = make_response(redirect(next_url))
resp.set_cookie(
'access_token',
serialize_token(token),
httponly=True,
samesite='Lax',
path='/'
)
"access_token", serialize_token(token), httponly=True, samesite="Lax", path="/"
)
return resp
@main.route('/set_token', methods=['POST', 'GET'])
@main.route("/set_token", methods=["POST", "GET"])
def set_token():
"""
Handles.
GET: Renders the registration form.
POST: Processes the form submission.
"""
if request.method == 'POST':
if request.method == "POST":
# Get form data
username = request.form.get('username')
username = request.form.get("username")
# Simple validation
if not username:
current_app.logger.debug("Login failed: No username supplied")
flash('Username is required.', 'error')
return render_template('set_token.html')
flash("Username is required.", "error")
return render_template("set_token.html")
if user_loader(username):
current_app.logger.info(
f"Admin username '{username}' entered in set_token. Redirecting to admin login."
)
return redirect(url_for("main.admin_login", prefill_user=username))
token = retrieve_token(username)
if not token:
flash(f'The username "{username}" does not exist.', 'error')
current_app.logger.warning(f"Login failed: Username '{username}' not found.")
return render_template('set_token.html')
flash(f'The username "{username}" does not exist.', "error")
current_app.logger.warning(
f"Login failed: Username '{username}' not found."
)
return render_template("set_token.html")
current_app.logger.info(f"User logged in: {username}")
return set_cookie(token)
return render_template('set_token.html')
return render_template("set_token.html")
@main.route('/register', methods=['POST', 'GET'])
@main.route("/register", methods=["POST", "GET"])
def register():
"""
Handles user registration.
GET: Renders the registration form.
POST: Processes the form submission.
"""
if request.method == 'POST':
if request.method == "POST":
# Get form data
user_name = request.form.get('username')
acknowledge = request.form.get('acknowledge')
user_name = request.form.get("username")
acknowledge = request.form.get("acknowledge")
# Simple validation
if not user_name:
flash('Username is required.', 'error')
flash("Username is required.", "error")
current_app.logger.debug("Username is required for registering")
return render_template('register.html')
return render_template("register.html")
if retrieve_token(user_name):
flash(f'The username "{user_name}" is already taken.', 'error')
flash(f'The username "{user_name}" is already taken.', "error")
current_app.logger.debug(f"Username {user_name} is already registered")
return render_template('register.html')
return render_template("register.html")
if not acknowledge:
flash('You must acknowledge the important information to proceed.', 'error')
flash("You must acknowledge the important information to proceed.", "error")
current_app.logger.debug("User did not acknowledge register information")
return render_template('register.html')
return render_template("register.html")
# If all validation passes, "register" the user
token = create_token(user_name)
token = create_token(user_name)
current_app.logger.info(f"New user registered: {user_name}")
return set_cookie(token)
# If GET request, render the form
return render_template('register.html')
return render_template("register.html")
@main.route('/logout', methods=['POST'])
@main.route("/logout", methods=["POST"])
def logout():
token = get_token()
user_name = token.name if token else "Unknown"
current_app.logger.info(f"User logout: {user_name}")
resp = make_response(redirect(url_for('main.index'))) # Redirect to a safe page, e.g., index or login
resp.set_cookie('access_token', '', expires=0) # Clears the cookie
resp = make_response(
redirect(url_for("main.index"))
) # Redirect to a safe page, e.g., index or login
resp.set_cookie("access_token", "", expires=0) # Clears the cookie
return resp
@main.route('/switch_course/<new_course>', methods=['POST'])
def switch_course(new_course: int):
token = get_token()
if not token:
return redirect(url_for('main.set_token'))
@main.route("/switch_course/<int:new_course>", methods=["POST"])
@token_required
def switch_course(new_course: int, token):
switched = get_course(new_course)
if switched:
token.active_course = switched
token.active_course = switched
db.session.commit()
current_app.logger.info(f"User {token.name} switched to course: {switched.name}")
current_app.logger.info(
f"User {token.name} switched to course: {switched.name}"
)
else:
current_app.logger.warning(f"User {token.name} attempted to switch to invalid course ID: {new_course}")
current_app.logger.warning(
f"User {token.name} attempted to switch to invalid course ID: {new_course}"
)
return redirect(url_for("main.index"))
@main.route('/admin/login', methods=['POST', 'GET'])
@main.route("/admin/login", methods=["POST", "GET"])
def admin_login():
form = LoginForm()
if form.validate_on_submit():
@@ -230,32 +257,27 @@ def admin_login():
if user and check_password(user, password):
flask_login.login_user(user)
current_app.logger.info(f"Admin login successful: {user_name}")
next = request.args.get('next')
if next is None:
return redirect(url_for('main.index'))
if not next.startswith('/admin'):
return abort(400)
token = retrieve_token(user_name)
if not token:
token = create_token(user_name)
return redirect(next)
next_url = request.args.get("next")
if next_url and next_url.startswith("/admin"):
session["next_url"] = next_url
else:
session["next_url"] = url_for("main.index")
return set_cookie(token)
else:
current_app.logger.warning(f"Failed admin login attempt: {user_name}")
flash('Incorrect username or password. Please try again.', 'error')
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 = get_token()
if not token:
# remember where the user wanted to go
session['next_url'] = url_for('main.access_get', access_code=access_code)
current_app.logger.debug("Access code used without session. Redirecting to login.")
return redirect(url_for('main.set_token'))
def process_access_code(access_code: str, token):
password = db.session.execute(
db.select(Password)
.where(Password.value == access_code)
db.select(Password).where(Password.value == access_code)
).scalar_one_or_none()
if password is not None:
@@ -264,141 +286,152 @@ def process_access_code(access_code: str):
current_app.logger.info(f"User {token.name} used valid access code.")
else:
current_app.logger.info(f"User {token.name} used expired access code.")
flash('Password has expired. Please obtain a new one.', 'expired')
flash("Password has expired. Please obtain a new one.", "expired")
else:
current_app.logger.warning(f"User {token.name} used invalid access code: {access_code}")
flash('Password is not valid. Please check the code and try again.', 'error')
return redirect(url_for('main.index'))
current_app.logger.warning(
f"User {token.name} used invalid access code: {access_code}"
)
flash("Password is not valid. Please check the code and try again.", "error")
return redirect(url_for("main.index"))
@main.route("/access", methods=["POST"])
@token_required
def access_post(token):
access_code = request.form.get("password")
@main.route('/access', methods=['POST'])
def access_post():
access_code = request.form.get('password')
if not access_code:
return redirect(url_for("main.index"))
return process_access_code(access_code)
return process_access_code(access_code, token)
# Route for direct link/QR code access (GET)
@main.route('/access/<access_code>', methods=['GET'])
def access_get(access_code: str):
return process_access_code(access_code)
@main.route("/access/<access_code>", methods=["GET"])
@token_required
def access_get(access_code: str, token):
return process_access_code(access_code, token)
@main.route('/confused', methods=['POST'])
@main.route("/confused", methods=["POST"])
def confused():
socketio.emit(
"confused",
{
"time": datetime.utcnow().isoformat()
},
to="instructor"
)
socketio.emit("confused", {"time": datetime.utcnow().isoformat()}, to="instructor")
return ("", 204)
def log_404(reason: str, deck: str, course: str, path: str):
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.
"""
token = get_token()
user_identity = f"User: {token.name} (ID: {token.id})" if token else "User: Anonymous"
current_app.logger.warning(f"404 Not Found [{reason}] | {user_identity} | Resource: {course}/{deck}/{path}")
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>')
def serve_deck(deck, course_folder, path='index.html'):
course = db.session.execute(db.select(Course).where(Course.folder==course_folder)).scalar()
if not course:
log_404("Course not found", deck, course_folder, 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)
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("Deck not in database", deck, course_folder, path)
if not deck_info["deck_exists"]:
log_404("Deck not in database", deck, course_folder, path, token)
abort(404)
assert isinstance(deck_obj, Deck)
if not is_deck_unlocked(deck_obj.deck):
# We generally do not log redirects for locked content as 404s,
# but you could log a warning here if desired.
current_app.logger.info(f"Access denied (locked) for user {get_token().name if get_token() else 'Anon'} -> {deck_obj.name}")
return redirect(url_for('main.index'))
# 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"))
slides_dir = os.path.join(current_app.config['SLIDES_DIR'], course.folder)
deck_path = os.path.join(slides_dir, deck_obj.deck)
# 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)
return f"Deck '{deck_obj.name}' not found.", 404
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_obj.name}")
return send_from_directory(deck_path, deck_obj.index_file)
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_obj.index_file:
current_app.logger.info(f"User {get_token().name if get_token() else 'Anon'} accessed deck {deck_obj.name}")
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)
log_404("File missing within deck", deck, course_folder, path, token)
abort(404)
@main.route('/exports/<course_folder>/<deck>/<path>')
def serve_export(deck, course_folder, path):
course = db.session.execute(db.select(Course).where(Course.folder==course_folder)).scalar()
@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)
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()
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)
log_404("Export: Deck not in database", deck, course_folder, path, token)
abort(404)
assert isinstance(deck_obj, Deck)
if not is_deck_unlocked(deck_obj.deck):
return redirect(url_for('main.index'))
exports_dir = os.path.join(current_app.config['EXPORTS_DIR'], course.folder)
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 {get_token().name if get_token() else 'Anon'} exported deck {deck_obj.name}")
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)
log_404("Export file missing", deck, course_folder, path, token)
abort(404)
@main.route('/admin', methods=['GET', 'POST'])
@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.")
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':
if request.method == "GET":
password_form.value.data = generate_password()
deck_form.deck.choices = get_available_decks(current_app, active_course)
decks = active_course.decks
decks = active_course.decks
password_form.decks.choices = [(deck.id, deck.name) for deck in decks]
if request.method == 'POST':
if request.method == "POST":
if "submit_deck" in request.form and deck_form.validate():
deck = Deck(
name=cast(str,deck_form.name.data),
name=cast(str, deck_form.name.data),
deck=deck_form.deck.data,
course=active_course,
description=cast(str,deck_form.description.data),
description=cast(str, deck_form.description.data),
)
db.session.add(deck)
db.session.commit()
@@ -409,28 +442,42 @@ def admin():
value: str = password_form.value.data
expires_at: datetime | None = password_form.expires_at.data
if expires_at:
tz = pytz.timezone('Europe/Berlin')
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 = 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})")
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'))
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)
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'])
@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
@@ -439,38 +486,46 @@ def switch_course_admin(new_course: int):
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'])
@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
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())
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'])
@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'))
return redirect(url_for("main.admin"))
@main.route('/admin/passwords/<int:password_id>/delete', methods=['POST'])
@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'))
return redirect(url_for("main.admin"))
@main.route('/admin/banner/show/<int:banner_id>', methods=['POST'])
@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)
@@ -482,19 +537,21 @@ def set_active_banner(banner_id):
banner.is_active = True
db.session.commit()
current_app.logger.info(f"Admin activated banner ID {banner_id}")
return redirect(url_for('main.banner'))
return redirect(url_for("main.banner"))
@main.route('/admin/banner/hide', methods=['POST'])
@main.route("/admin/banner/hide", methods=["POST"])
@flask_login.login_required
def hide_active_banner():
banner = get_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'))
return redirect(url_for("main.banner"))
@main.route('/admin/confused')
@main.route("/admin/confused")
@flask_login.login_required
def confused_notifications():
return render_template("confused.html")