commit c043c8702916d7da966da28648a46fb51ac36732 Author: cato447 Date: Fri Apr 25 18:37:37 2025 +0200 v 0.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbca4c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +instance +__pycache__ +node_modules +.venv +config.py +slides diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..7359b4e --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,20 @@ +from flask import Flask +from .routes import main +from .models import db +from .utils import init_password_generator, init_serializer +from .auth import init_auth + +def create_app(): + app = Flask(__name__, static_folder='static', static_url_path='/static') + app.config.from_object('config.Config') + db.init_app(app) + + with app.app_context(): + db.create_all() + + init_password_generator() + init_serializer(app) + init_auth(app) + + app.register_blueprint(main) + return app diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..79b7c0d --- /dev/null +++ b/app/auth.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Dict +import flask_login +from flask import Flask + +login_manager = flask_login.LoginManager() +users: Dict[str, User] + +def init_auth(app: Flask): + login_manager.init_app(app) + login_manager.login_view = "main.login" # type: ignore + global users + users = {"admin": User("admin", app.config["ADMIN_PASSWORD"])} + + +class User(flask_login.UserMixin): + def __init__(self, id, password): + self.id = id + self.password = password + +@login_manager.user_loader +def user_loader(id: str): + return users.get(id) + +def check_password(user: User, input: str): + return user.password == input + diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 0000000..2e2364b --- /dev/null +++ b/app/forms.py @@ -0,0 +1,21 @@ +from flask_wtf import FlaskForm +from wtforms import PasswordField, StringField, SubmitField, TextAreaField, DateTimeField, SelectField, SelectMultipleField +from wtforms.validators import DataRequired, Optional + +class DeckForm(FlaskForm): + name = StringField("Title", validators=[DataRequired()]) + deck = SelectField("Deck", validators=[DataRequired()]) + description = TextAreaField("Description", validators=[Optional()]) + submit_deck = SubmitField("Submit Deck") + + +class PasswordForm(FlaskForm): + value = StringField("Password", validators=[DataRequired()]) + expires_at = DateTimeField("Expires At (Timezone: Europe/Berlin, optional)", format="%Y-%m-%dT%H:%M", validators=[Optional()]) + decks = SelectMultipleField("Decks", validators=[DataRequired()]) + submit_password = SubmitField("Submit Password") + + +class LoginForm(FlaskForm): + user_name = StringField("Username", validators=[DataRequired()]) + password = PasswordField("Password", validators=[DataRequired()]) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..57a7503 --- /dev/null +++ b/app/models.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import List, Optional + +import pytz +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import Column, DateTime, String, TypeDecorator +from sqlalchemy.orm import Mapped, mapped_column, relationship + +db : SQLAlchemy = SQLAlchemy() + +deck_password = db.Table( + "deck_password", + Column("deck_id", db.Integer, db.ForeignKey("deck.id"), primary_key=True), + Column("password_id", db.Integer, db.ForeignKey("password.id"), primary_key=True) +) + +deck_token = db.Table( + "deck_token", + Column("deck_id", db.Integer, db.ForeignKey("deck.id"), primary_key=True), + Column("token_id", db.Integer, db.ForeignKey("token.id"), primary_key=True) +) + + +class UTCDateTime(TypeDecorator): + impl = DateTime + + def process_bind_param(self, value, dialect): + # Ensure datetime is stored in naive UTC + if value is not None and value.tzinfo is not None: + value = value.astimezone(pytz.utc).replace(tzinfo=None) + return value + + def process_result_value(self, value, dialect): + # Re-attach UTC tzinfo when reading + if value is not None: + value = value.replace(tzinfo=pytz.utc) + return value + +class Password(db.Model): + __tablename__ = "password" + + id : Mapped[int] = mapped_column(primary_key=True) + value : Mapped[str] = mapped_column(String(60)) + expires_at: Mapped[Optional[datetime]] = mapped_column(UTCDateTime()) # Optional expiration + + decks : Mapped[List[Deck]] = relationship("Deck", secondary=deck_password, back_populates='passwords') + + def __init__(self, value: str, expires_at: datetime|None) -> None: + self.value = value + self.expires_at = expires_at + + def is_expired(self) -> bool: + """Check if the password is expired""" + return self.expires_at is not None and datetime.now(timezone.utc) > self.expires_at + + def __repr__(self) -> str: + return f"" + + +class Deck(db.Model): + __tablename__ = "deck" + + id : Mapped[int] = mapped_column(primary_key=True) + name : Mapped[str] = mapped_column(String(60), unique=True, nullable=False) + deck : Mapped[str] = mapped_column(String(60), unique=True, nullable=False) + description : Mapped[str] = mapped_column(String(60)) + + passwords : Mapped[List[Password]] = relationship("Password", secondary=deck_password, back_populates='decks') + tokens : Mapped[List[Token]] = relationship("Token", secondary=deck_token, back_populates='decks') + + def __init__(self, name: str, deck: str, description: str = "") -> None: + self.name = name + self.deck = deck + self.description = description + + def __repr__(self) -> str: + return f"" + +class Token(db.Model): + __tablename__ = "token" + id : Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(60), unique=True, nullable=False) + decks : Mapped[List[Deck]]= relationship("Deck", secondary=deck_token, back_populates='tokens') + + def __init__(self, name: str) -> None: + self.name = name + + def __repr__(self) -> str: + return f"" + + def is_unlocked(self, deck: str) -> bool : + return deck in [deck.deck for deck in self.decks] + diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..b434abd --- /dev/null +++ b/app/routes.py @@ -0,0 +1,172 @@ +from datetime import datetime, timezone +import os +import re +from typing import cast, List + +from flask import ( + Blueprint, + abort, + current_app, + flash, + make_response, + redirect, + render_template, + request, + send_from_directory, + url_for, +) +import flask_login +import pytz +from sqlalchemy.sql import expression + +from .auth import check_password, user_loader +from .forms import DeckForm, LoginForm, PasswordForm +from .models import Deck, Password, db +from .utils import ( + generate_token, + get_available_decks, + get_token, + get_unlocked_decks, + serialize_token, + is_deck_unlocked, + update_token, + generate_password, +) + +main = Blueprint('main', __name__) +slidev_slide_pattern = re.compile(r'\d+$') + +@main.route('/') +def index(): + token = get_token() + if not token: + token = generate_token() + resp = make_response(render_template('index.html', decks=get_unlocked_decks(token), user_name=token.name)) + resp.set_cookie('access_token', serialize_token(token), httponly=True) + return resp + +@main.route('/login', methods=['POST', 'GET']) +def 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) + flash('Login successful!', 'success') + + next = request.args.get('next') + if next is None: + return redirect(url_for('main.index')) + + if not next.startswith('/admin'): + return abort(400) + + return redirect(next) + else: + flash('Incorrect username or password. Please try again.', 'error') + return render_template("login.html", form=form) + + +@main.route('/access', methods=['POST']) +def access(): + password = db.session.execute(db.select(Password).where(Password.value == request.form.get('password'))).scalar() + assert isinstance(password, Password|None) + token = get_token() + if password is not None and not password.is_expired(): + update_token(password.decks) + return redirect('/') + return render_template('index.html', error="Invalid password.", decks=get_unlocked_decks(token)) + + +@main.route('/slides//') +@main.route('/slides//') +def serve_deck(deck, path='index.html'): + if not db.session.execute(db.select(Deck).where(Deck.deck==deck)).scalar(): + abort(404) + + if not is_deck_unlocked(deck): + return redirect('/') + + slides_dir = current_app.config['SLIDES_DIR'] + deck_path = os.path.join(slides_dir, deck) + if not os.path.exists(deck_path): + return f"Deck '{deck}' not found.", 404 + + if slidev_slide_pattern.match(path): + return send_from_directory(deck_path, 'index.html') + + requested_file = os.path.join(deck_path, path) + if os.path.isfile(requested_file): + return send_from_directory(deck_path, path) + + abort(404) + +@main.route('/admin', methods=['GET', 'POST']) +@flask_login.login_required +def admin(): + deck_form = DeckForm() + password_form = PasswordForm() + password_form.value.data = generate_password() + deck_form.deck.choices = get_available_decks(current_app) + decks = db.session.scalars(db.select(Deck).order_by(Deck.name)).all() + passwords = db.session.scalars(db.select(Password)).all() + 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, + description=cast(str,deck_form.description.data), + ) + db.session.add(deck) + db.session.commit() + + 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() + else: + # Form was submitted but validation failed + for field, errors in password_form.errors.items(): + for error in errors: + print(f"Error in {getattr(password_form, field).label.text}: {error}", flush=True) + return redirect(url_for('main.admin')) + + return render_template("admin.html", deck_form=deck_form, password_form=password_form, decks=decks, passwords=passwords) + + +@main.route('/admin/decks//delete', methods=['POST']) +@flask_login.login_required +def delete_deck(deck_id: int): + deck = Deck.query.get_or_404(deck_id) + db.session.delete(deck) + db.session.commit() + flash(f'Deck "{deck.name}" deleted.', 'success') + return redirect(url_for('main.admin')) + +@main.route('/admin/passwords//delete', methods=['POST']) +@flask_login.login_required +def delete_password(password_id: int): + password = Password.query.get_or_404(password_id) + db.session.delete(password) + db.session.commit() + flash(f'Password "{password.value}" deleted.', 'success') + return redirect(url_for('main.admin')) diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..6de0746 --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,150 @@ + + + + + + + Deck Admin + + + + +
+ + +
+
+

Add New Deck

+
+ {{ deck_form.hidden_tag() }} + +
+ + {{ deck_form.name(class="form-input mt-1 block w-full") }} +
+ +
+ + {{ deck_form.deck(multiple=True, class="form-input mt-1 block w-full") }} +
+ +
+ + {{ deck_form.description(class="form-input mt-1 block w-full") }} +
+ +
+ {{ deck_form.submit_deck(class="bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 w-full") }} +
+
+

Existing Decks

+ {% if decks %} + + + + + + + + + + {% for deck in decks %} + + + + + + {% endfor %} + +
NamePasswordsAction
{{ deck.name }} + {% if deck.passwords %} +
    + {% for password in deck.passwords %} +
  • {{ password.value }}
  • + {% endfor %} +
+ {% else %} + None + {% endif %} +
+
+ +
+
+ {% else %} +

No decks available.

+ {% endif %} +
+ + +
+

Add New Password

+
+ {{ password_form.hidden_tag() }} + +
+ + {{ password_form.value(class="form-input mt-1 block w-full") }} +
+ +
+ + {{ password_form.expires_at(class="form-input mt-1 block w-full", type="datetime-local") }} +
+ +
+ + {{ password_form.decks(class="form-input mt-1 block w-full") }} +
+ +
+ {{ password_form.submit_password(class="bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 w-full") }} +
+
+ +

Existing Passwords

+ {% if passwords %} + + + + + + + + + + + {% for password in passwords %} + + + + + + + {% endfor %} + +
PasswordExpired byDecksAction
{{ password.value }}{{ password.expires_at }} + {% if password.decks %} +
    + {% for deck in password.decks %} +
  • {{ deck.name }}
  • + {% endfor %} +
+ {% else %} + None + {% endif %} +
+
+ +
+
+ {% else %} +

No passwords available.

+ {% endif %} +
+ + + + + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..9a7f3b1 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,100 @@ + + + + + + Tutorial Materials + + + + + + + + + + + + + + +
+
+
{{ user_name }}
+
GRNVS Tutorium
+ + + +
+
+ + +
+ + +
+
+
+

Enter Access Password

+
+ + +
+ {% if error %} +

{{ error }}

+ {% endif %} +
+
+
+ + + {% if decks %} +
+
+ {% for deck in decks %} + +

{{ deck.name }}

+

{{ deck.description }}

+
+ {% endfor %} +
+
+ {% else %} +

No decks unlocked yet.

+ {% endif %} + +
+ + + + diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..166a68a --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,108 @@ + + + + + Login + + + + + + + + + +
+

Login

+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ {{ form.hidden_tag() }} + + +
+ + {{ form.user_name(class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500") }} + {% for error in form.user_name.errors %} +

{{ error }}

+ {% endfor %} +
+ + +
+ +
+ {{ form.password( + class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500", + id="password" + ) }} + +
+ {% for error in form.password.errors %} +

{{ error }}

+ {% endfor %} +
+ + +
+ +
+
+
+ + + + + + + diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..fbae87e --- /dev/null +++ b/app/utils.py @@ -0,0 +1,83 @@ +import os + +from flask import Flask, request +from typing import cast +from itsdangerous import BadSignature, URLSafeSerializer +from xkcdpass import xkcd_password + +from .models import Deck, Token, db + +def init_password_generator(): + wordfile = xkcd_password.locate_wordfile() + global words + words = xkcd_password.generate_wordlist(wordfile=wordfile, min_length=3, max_length=6) + +def init_serializer(app: Flask): + global serializer + serializer = URLSafeSerializer(app.config['SECRET_KEY']) + +def get_available_decks(app: Flask): + slides_dir = app.config['SLIDES_DIR'] + deck_names = [name for name in os.listdir(slides_dir) if os.path.isdir(os.path.join(slides_dir, name))] + deck_names.sort() + return deck_names + +def generate_username() -> str: + return cast(str, xkcd_password.generate_xkcdpassword(wordlist=words, numwords=3, delimiter="-")) + +def generate_password() -> str: + return cast(str, xkcd_password.generate_xkcdpassword(wordlist=words, numwords=5, delimiter="-")) + +def generate_token() -> Token: + max_tries = 100 + for _ in range(max_tries): + user_name = generate_username() + + if not db.session.execute(db.select(Token).where(Token.name == user_name)).scalar(): + break + else: + raise RuntimeError(f"Unique username could not be generated after {max_tries} tries") + + token = Token(user_name) + db.session.add(token) + db.session.commit() + return token + +def serialize_token(token: Token) -> str: + return serializer.dumps(token.id) + +def get_token() -> (Token | None): + token = request.cookies.get('access_token') + if not token: + return None + try: + id = serializer.loads(token) + token = db.session.execute(db.select(Token).filter_by(id=id)).scalar_one_or_none() + return token + except BadSignature: + return None + +def get_unlocked_decks(token: (Token|None)): + if token is None: + return [] + decks = token.decks + decks.sort(key=lambda deck: deck.deck) + return decks + +def is_deck_unlocked(deck: str): + token = get_token() + if token: + return token.is_unlocked(deck) + else: + return False + +def update_token(decks: list[Deck]): + """ + Adds the deck to the list of decks stored in the access token + """ + token = get_token() + if token is None: + raise ValueError("Invalid token is set") + else: + token.decks = list(set(decks + token.decks)) + db.session.commit() diff --git a/run.py b/run.py new file mode 100644 index 0000000..3dc5f39 --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == '__main__': + app.run()