This commit is contained in:
2025-04-25 18:37:37 +02:00
commit c043c87029
11 changed files with 789 additions and 0 deletions

20
app/__init__.py Normal file
View File

@@ -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

28
app/auth.py Normal file
View File

@@ -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

21
app/forms.py Normal file
View File

@@ -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()])

95
app/models.py Normal file
View File

@@ -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"<Password(value={self.value}, expires_at={self.expires_at})>"
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"<Deck(name={self.name}, deck={self.deck})>"
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"<Token(id={self.id})>"
def is_unlocked(self, deck: str) -> bool :
return deck in [deck.deck for deck in self.decks]

172
app/routes.py Normal file
View File

@@ -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/<deck>/')
@main.route('/slides/<deck>/<path:path>')
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/<int:deck_id>/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/<int:password_id>/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'))

150
app/templates/admin.html Normal file
View File

@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deck Admin</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@4.1.4/dist/tailwind.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100">
<div class="container mx-auto p-6">
<!-- Add New Deck Form -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white p-6 rounded-lg shadow-md">
<h2 class="text-xl font-semibold mb-4">Add New Deck</h2>
<form method="POST">
{{ deck_form.hidden_tag() }}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">{{ deck_form.name.label }}</label>
{{ deck_form.name(class="form-input mt-1 block w-full") }}
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">{{ deck_form.deck.label }}</label>
{{ deck_form.deck(multiple=True, class="form-input mt-1 block w-full") }}
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">{{ deck_form.description.label }}</label>
{{ deck_form.description(class="form-input mt-1 block w-full") }}
</div>
<div class="mb-4">
{{ deck_form.submit_deck(class="bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 w-full") }}
</div>
</form>
<h2 class="text-xl font-semibold mb-4">Existing Decks</h2>
{% if decks %}
<table class="min-w-full table-auto text-left text-gray-700">
<thead>
<tr class="border-b">
<th class="px-4 py-2">Name</th>
<th class="px-4 py-2">Passwords</th>
<th class="px-4 py-2">Action</th>
</tr>
</thead>
<tbody>
{% for deck in decks %}
<tr class="border-b">
<td class="px-4 py-2">{{ deck.name }}</td>
<td class="px-4 py-2">
{% if deck.passwords %}
<ul class="list-disc pl-5">
{% for password in deck.passwords %}
<li>{{ password.value }}</li>
{% endfor %}
</ul>
{% else %}
<em>None</em>
{% endif %}
</td>
<td class="px-4 py-2">
<form method="POST" action="{{ url_for('main.delete_deck', deck_id=deck.id) }}" style="display:inline;">
<button type="submit" class="bg-red-500 text-white py-1 px-4 rounded-md hover:bg-red-600">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-center mt-4 text-gray-500 dark:text-gray-300">No decks available.</p>
{% endif %}
</div>
<!-- Add New Password Form -->
<div class="bg-white p-6 rounded-lg shadow-md">
<h2 class="text-xl font-semibold mb-4">Add New Password</h2>
<form method="POST">
{{ password_form.hidden_tag() }}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">{{ password_form.value.label }}</label>
{{ password_form.value(class="form-input mt-1 block w-full") }}
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">{{ password_form.expires_at.label }}</label>
{{ password_form.expires_at(class="form-input mt-1 block w-full", type="datetime-local") }}
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">{{ password_form.decks.label }}</label>
{{ password_form.decks(class="form-input mt-1 block w-full") }}
</div>
<div class="mb-4">
{{ password_form.submit_password(class="bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 w-full") }}
</div>
</form>
<!-- Password Cards -->
<h2 class="text-xl font-semibold mb-4">Existing Passwords</h2>
{% if passwords %}
<table class="min-w-full table-auto text-left text-gray-700">
<thead>
<tr class="border-b">
<th class="px-4 py-2">Password</th>
<th class="px-4 py-2">Expired by</th>
<th class="px-4 py-2">Decks</th>
<th class="px-4 py-2">Action</th>
</tr>
</thead>
<tbody>
{% for password in passwords %}
<tr class="border-b">
<td class="px-4 py-2">{{ password.value }}</td>
<td class="px-4 py-2">{{ password.expires_at }}</td>
<td class="px-4 py-2">
{% if password.decks %}
<ul class="list-disc pl-5">
{% for deck in password.decks %}
<li>{{ deck.name }}</li>
{% endfor %}
</ul>
{% else %}
<em>None</em>
{% endif %}
</td>
<td class="px-4 py-2">
<form method="POST" action="{{ url_for('main.delete_password', password_id=password.id) }}" style="display:inline;">
<button type="submit" class="bg-red-500 text-white py-1 px-4 rounded-md hover:bg-red-600">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-center mt-4 text-gray-500 dark:text-gray-300">No passwords available.</p>
{% endif %}
</div>
<!-- Flowbite Script -->
<script src="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.js"></script>
</body>
</html>

100
app/templates/index.html Normal file
View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tutorial Materials</title>
<!-- Tailwind CSS v4.1.4 CDN -->
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script>
<!-- Tailwind Config (Dark Mode + Theme Colors) -->
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
background: '#09090b',
surface: '#18181b',
border: '#27272a',
primary: '#3b82f6',
text: '#f4f4f5',
muted: '#a1a1aa',
},
},
}
};
</script>
<!-- Flowbite -->
<link href="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.css" rel="stylesheet" />
</head>
<body class="bg-background text-text min-h-screen font-sans">
<!-- Header -->
<header class="bg-surface border-b border-border shadow-sm">
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="text-sm text-muted font-medium">{{ user_name }}</div>
<div class="text-lg font-semibold">GRNVS Tutorium</div>
<a href="/admin">
<button class="bg-primary hover:bg-blue-700 text-white px-4 py-2 rounded-md transition">
Admin
</button>
</a>
</div>
</header>
<!-- Main Content -->
<main class="max-w-5xl mx-auto px-6 py-10 space-y-12">
<!-- Password Form -->
<section class="flex justify-center">
<div class="w-full max-w-md">
<div class="bg-surface border border-border rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-center mb-4">Enter Access Password</h2>
<form method="POST" action="/access" class="space-y-4">
<input
type="password"
name="password"
placeholder="Enter password"
required
autofocus
class="w-full px-4 py-3 rounded-md bg-background border border-border text-text placeholder-muted focus:outline-none focus:ring-2 focus:ring-primary"
/>
<button
type="submit"
class="w-full bg-primary hover:bg-blue-700 text-white py-2 rounded-md transition"
>
Unlock
</button>
</form>
{% if error %}
<p class="text-red-500 text-center mt-3">{{ error }}</p>
{% endif %}
</div>
</div>
</section>
<!-- Deck Cards -->
{% if decks %}
<section>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{% for deck in decks %}
<a href="/slides/{{ deck.deck }}/" 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">{{ deck.name }}</h3>
<p class="text-muted text-sm">{{ deck.description }}</p>
</a>
{% endfor %}
</div>
</section>
{% else %}
<p class="text-center text-muted">No decks unlocked yet.</p>
{% endif %}
</main>
<script src="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.js"></script>
</body>
</html>

108
app/templates/login.html Normal file
View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<title>Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Flowbite -->
<script src="https://unpkg.com/flowbite@2.3.0/dist/flowbite.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
};
</script>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-sm p-6 bg-gray-800 rounded-2xl shadow-lg">
<h2 class="text-2xl font-bold mb-6 text-center text-white">Login</h2>
<!-- Display Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-4">
{% for category, message in messages %}
<div class="text-sm p-3 rounded-md {{ 'bg-red-600' if category == 'error' else 'bg-green-600' }} text-white">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<!-- Username Field -->
<div class="mb-4">
<label for="user_name" class="block text-sm font-medium mb-1 text-gray-200">
{{ form.user_name.label.text }}
</label>
{{ 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 %}
<p class="text-red-400 text-sm mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Password Field with Toggle -->
<div class="mb-6">
<label for="password" class="block text-sm font-medium mb-1 text-gray-200">
{{ form.password.label.text }}
</label>
<div class="relative">
{{ 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"
) }}
<button type="button"
onclick="togglePassword()"
class="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-white"
tabindex="-1"
aria-label="Toggle password visibility">
<svg id="eyeIcon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</div>
{% for error in form.password.errors %}
<p class="text-red-400 text-sm mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Submit Button -->
<div>
<button type="submit"
class="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg shadow-md transition duration-300">
Log In
</button>
</div>
</form>
</div>
<!-- Password Toggle Script -->
<script>
function togglePassword() {
const passwordInput = document.getElementById("password");
const icon = document.getElementById("eyeIcon");
const isHidden = passwordInput.type === "password";
passwordInput.type = isHidden ? "text" : "password";
icon.innerHTML = isHidden
? `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.542-7a9.953 9.953 0 012.159-3.362m2.649-2.35A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.542 7a9.953 9.953 0 01-4.042 4.442M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 3l18 18" />`
: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />`;
}
</script>
</body>
</html>

83
app/utils.py Normal file
View File

@@ -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()