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