v 0.0.1
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
instance
|
||||
__pycache__
|
||||
node_modules
|
||||
.venv
|
||||
config.py
|
||||
slides
|
||||
20
app/__init__.py
Normal file
20
app/__init__.py
Normal 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
28
app/auth.py
Normal 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
21
app/forms.py
Normal 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
95
app/models.py
Normal 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
172
app/routes.py
Normal 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
150
app/templates/admin.html
Normal 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 [48;48;178;1632;2848tmt-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
100
app/templates/index.html
Normal 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
108
app/templates/login.html
Normal 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
83
app/utils.py
Normal 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()
|
||||
Reference in New Issue
Block a user