safety commit
This commit is contained in:
@@ -19,3 +19,8 @@ class PasswordForm(FlaskForm):
|
|||||||
class LoginForm(FlaskForm):
|
class LoginForm(FlaskForm):
|
||||||
user_name = StringField("Username", validators=[DataRequired()])
|
user_name = StringField("Username", validators=[DataRequired()])
|
||||||
password = PasswordField("Password", validators=[DataRequired()])
|
password = PasswordField("Password", validators=[DataRequired()])
|
||||||
|
|
||||||
|
|
||||||
|
class InfoBannerForm(FlaskForm):
|
||||||
|
content = StringField("Content", validators=[DataRequired()])
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import List, Optional
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from sqlalchemy import Column, DateTime, String, TypeDecorator
|
from sqlalchemy import Boolean, Column, DateTime, String, TypeDecorator
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
db : SQLAlchemy = SQLAlchemy()
|
db : SQLAlchemy = SQLAlchemy()
|
||||||
@@ -45,7 +45,7 @@ class Password(db.Model):
|
|||||||
value : Mapped[str] = mapped_column(String(60))
|
value : Mapped[str] = mapped_column(String(60))
|
||||||
expires_at: Mapped[Optional[datetime]] = mapped_column(UTCDateTime()) # Optional expiration
|
expires_at: Mapped[Optional[datetime]] = mapped_column(UTCDateTime()) # Optional expiration
|
||||||
|
|
||||||
decks : Mapped[List[Deck]] = relationship("Deck", secondary=deck_password, back_populates='passwords')
|
decks : Mapped[List[Deck]] = relationship("Deck", secondary=deck_password, back_populates='passwords', cascade="all")
|
||||||
|
|
||||||
def __init__(self, value: str, expires_at: datetime|None) -> None:
|
def __init__(self, value: str, expires_at: datetime|None) -> None:
|
||||||
self.value = value
|
self.value = value
|
||||||
@@ -67,8 +67,8 @@ class Deck(db.Model):
|
|||||||
deck : 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))
|
description : Mapped[str] = mapped_column(String(60))
|
||||||
|
|
||||||
passwords : Mapped[List[Password]] = relationship("Password", secondary=deck_password, back_populates='decks')
|
passwords : Mapped[List[Password]] = relationship("Password", secondary=deck_password, back_populates='decks', cascade="all")
|
||||||
tokens : Mapped[List[Token]] = relationship("Token", secondary=deck_token, back_populates='decks')
|
tokens : Mapped[List[Token]] = relationship("Token", secondary=deck_token, back_populates='decks', cascade="all")
|
||||||
|
|
||||||
def __init__(self, name: str, deck: str, description: str = "") -> None:
|
def __init__(self, name: str, deck: str, description: str = "") -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -82,7 +82,7 @@ class Token(db.Model):
|
|||||||
__tablename__ = "token"
|
__tablename__ = "token"
|
||||||
id : Mapped[int] = mapped_column(primary_key=True)
|
id : Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(60), unique=True, nullable=False)
|
name: Mapped[str] = mapped_column(String(60), unique=True, nullable=False)
|
||||||
decks : Mapped[List[Deck]]= relationship("Deck", secondary=deck_token, back_populates='tokens')
|
decks : Mapped[List[Deck]]= relationship("Deck", secondary=deck_token, back_populates='tokens', cascade="all")
|
||||||
|
|
||||||
def __init__(self, name: str) -> None:
|
def __init__(self, name: str) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -93,3 +93,13 @@ class Token(db.Model):
|
|||||||
def is_unlocked(self, deck: str) -> bool :
|
def is_unlocked(self, deck: str) -> bool :
|
||||||
return deck in [deck.deck for deck in self.decks]
|
return deck in [deck.deck for deck in self.decks]
|
||||||
|
|
||||||
|
class InfoBanner(db.Model):
|
||||||
|
__tablename__ = "info_banner"
|
||||||
|
id : Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
content : Mapped[str] = mapped_column(String(280))
|
||||||
|
is_active : Mapped[bool] = mapped_column(Boolean)
|
||||||
|
|
||||||
|
def __init__(self, content : str) -> None:
|
||||||
|
self.content = content
|
||||||
|
self.is_active = False
|
||||||
|
|
||||||
|
|||||||
110
app/routes.py
110
app/routes.py
@@ -1,13 +1,16 @@
|
|||||||
from datetime import datetime, timezone
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import cast, List
|
from datetime import datetime
|
||||||
|
from typing import List, cast
|
||||||
|
|
||||||
|
import flask_login
|
||||||
|
import pytz
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
abort,
|
abort,
|
||||||
current_app,
|
current_app,
|
||||||
flash,
|
flash,
|
||||||
|
jsonify,
|
||||||
make_response,
|
make_response,
|
||||||
redirect,
|
redirect,
|
||||||
render_template,
|
render_template,
|
||||||
@@ -15,33 +18,76 @@ from flask import (
|
|||||||
send_from_directory,
|
send_from_directory,
|
||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
import flask_login
|
|
||||||
import pytz
|
|
||||||
from sqlalchemy.sql import expression
|
|
||||||
|
|
||||||
from .auth import check_password, user_loader
|
from .auth import check_password, user_loader
|
||||||
from .forms import DeckForm, LoginForm, PasswordForm
|
from .forms import DeckForm, InfoBannerForm, LoginForm, PasswordForm
|
||||||
from .models import Deck, Password, db
|
from .models import Deck, InfoBanner, Password, db
|
||||||
from .utils import (
|
from .utils import (
|
||||||
|
generate_password,
|
||||||
generate_token,
|
generate_token,
|
||||||
|
get_active_banner,
|
||||||
get_available_decks,
|
get_available_decks,
|
||||||
get_token,
|
get_token,
|
||||||
get_unlocked_decks,
|
get_unlocked_decks,
|
||||||
serialize_token,
|
|
||||||
is_deck_unlocked,
|
is_deck_unlocked,
|
||||||
|
serialize_token,
|
||||||
update_token,
|
update_token,
|
||||||
generate_password,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
main = Blueprint('main', __name__)
|
main = Blueprint('main', __name__)
|
||||||
slidev_slide_pattern = re.compile(r'\d+$')
|
slidev_deck_pattern = re.compile(r"^\/slides\/[^\/]+\/\d*(?:\?.*)?$")
|
||||||
|
slidev_all_pattern = re.compile(r"^\/slides\/[^\/]+(?:\/presenter)?\/\d+(?:\?.*)?$")
|
||||||
|
|
||||||
|
@main.after_request
|
||||||
|
def inject_confused_button(response):
|
||||||
|
if True:
|
||||||
|
if (slidev_deck_pattern.match(request.path) or request.path == '/')and response.content_type.startswith('text/html'):
|
||||||
|
if response.direct_passthrough:
|
||||||
|
response.direct_passthrough = False
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
insert_at = body.find('</body>')
|
||||||
|
if insert_at != -1:
|
||||||
|
button_html = """
|
||||||
|
<button id="confused-button" style="
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 9999;
|
||||||
|
background-color: #4f8cff;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s, transform 0.2s;"
|
||||||
|
title="Didn't understand? Click!">
|
||||||
|
Ich habe das nicht verstanden
|
||||||
|
</button>
|
||||||
|
<script>
|
||||||
|
document.getElementById('confused-button').addEventListener('click', () => {
|
||||||
|
fetch('/confused', {method: 'POST'})
|
||||||
|
.then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Alles klar! Ich versuche es besser zu erklären. Melde dich, wenn du eine spezifische Frage hast oder noch etwas unklar bleibt.');
|
||||||
|
}
|
||||||
|
}).catch(error => console.error('Error:', error));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
|
body = body[:insert_at] + button_html + body[insert_at:]
|
||||||
|
response.set_data(body)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@main.route('/')
|
@main.route('/')
|
||||||
def index():
|
def index():
|
||||||
token = get_token()
|
token = get_token()
|
||||||
if not token:
|
if not token:
|
||||||
token = generate_token()
|
token = generate_token()
|
||||||
resp = make_response(render_template('index.html', decks=get_unlocked_decks(token), user_name=token.name))
|
resp = make_response(render_template('index.html', active_banner=get_active_banner(), decks=get_unlocked_decks(token), user_name=token.name))
|
||||||
resp.set_cookie('access_token', serialize_token(token), httponly=True)
|
resp.set_cookie('access_token', serialize_token(token), httponly=True)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@@ -81,6 +127,12 @@ def access():
|
|||||||
return redirect('/')
|
return redirect('/')
|
||||||
return render_template('index.html', error="Invalid password.", decks=get_unlocked_decks(token))
|
return render_template('index.html', error="Invalid password.", decks=get_unlocked_decks(token))
|
||||||
|
|
||||||
|
@main.route('/confused', methods=['POST'])
|
||||||
|
|
||||||
|
def confused():
|
||||||
|
resp = jsonify(success=True)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@main.route('/slides/<deck>/')
|
@main.route('/slides/<deck>/')
|
||||||
@main.route('/slides/<deck>/<path:path>')
|
@main.route('/slides/<deck>/<path:path>')
|
||||||
@@ -96,7 +148,7 @@ def serve_deck(deck, path='index.html'):
|
|||||||
if not os.path.exists(deck_path):
|
if not os.path.exists(deck_path):
|
||||||
return f"Deck '{deck}' not found.", 404
|
return f"Deck '{deck}' not found.", 404
|
||||||
|
|
||||||
if slidev_slide_pattern.match(path):
|
if slidev_all_pattern.match(request.path):
|
||||||
return send_from_directory(deck_path, 'index.html')
|
return send_from_directory(deck_path, 'index.html')
|
||||||
|
|
||||||
requested_file = os.path.join(deck_path, path)
|
requested_file = os.path.join(deck_path, path)
|
||||||
@@ -152,6 +204,17 @@ def admin():
|
|||||||
|
|
||||||
return render_template("admin.html", deck_form=deck_form, password_form=password_form, decks=decks, passwords=passwords)
|
return render_template("admin.html", deck_form=deck_form, password_form=password_form, decks=decks, passwords=passwords)
|
||||||
|
|
||||||
|
@main.route('/admin/banner', methods=['POST', 'GET'])
|
||||||
|
@flask_login.login_required
|
||||||
|
def banner():
|
||||||
|
banner_form = InfoBannerForm()
|
||||||
|
if banner_form.validate_on_submit():
|
||||||
|
assert isinstance(banner_form.content.data, str)
|
||||||
|
content : str = banner_form.content.data
|
||||||
|
banner = InfoBanner(content=content)
|
||||||
|
db.session.add(banner)
|
||||||
|
db.session.commit()
|
||||||
|
return render_template("banner.html", banner_form=banner_form, banners=db.session.execute(db.select(InfoBanner)).scalars())
|
||||||
|
|
||||||
@main.route('/admin/decks/<int:deck_id>/delete', methods=['POST'])
|
@main.route('/admin/decks/<int:deck_id>/delete', methods=['POST'])
|
||||||
@flask_login.login_required
|
@flask_login.login_required
|
||||||
@@ -170,3 +233,26 @@ def delete_password(password_id: int):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Password "{password.value}" deleted.', 'success')
|
flash(f'Password "{password.value}" deleted.', 'success')
|
||||||
return redirect(url_for('main.admin'))
|
return redirect(url_for('main.admin'))
|
||||||
|
|
||||||
|
@main.route('/admin/banner/show/<int:banner_id>', methods=['POST'])
|
||||||
|
@flask_login.login_required
|
||||||
|
def set_active_banner(banner_id):
|
||||||
|
banner = InfoBanner.query.get(banner_id)
|
||||||
|
if banner:
|
||||||
|
# ensures that only one banner is active at all times
|
||||||
|
active_banner = get_active_banner()
|
||||||
|
if active_banner:
|
||||||
|
active_banner.is_active = False
|
||||||
|
banner.is_active = True
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(url_for('main.banner'))
|
||||||
|
|
||||||
|
@main.route('/admin/banner/hide', methods=['POST'])
|
||||||
|
@flask_login.login_required
|
||||||
|
def hide_active_banner():
|
||||||
|
banner = get_active_banner()
|
||||||
|
if banner:
|
||||||
|
banner.is_active = False
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(url_for('main.banner'))
|
||||||
|
|
||||||
|
|||||||
100
app/templates/banner.html
Normal file
100
app/templates/banner.html
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Banner</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: '#121418', // dark slate with blue/gray tone
|
||||||
|
surface: '#1e2127', // a bit lighter for containers/cards
|
||||||
|
border: '#2a2d34', // visible but subtle borders
|
||||||
|
primary: '#4f8cff', // soft blue that pops but is not neon
|
||||||
|
text: '#e4e4e7', // light gray for high contrast
|
||||||
|
muted: '#a0a0ad', // for subdued text
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</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-lg font-semibold">Info Banner</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="max-w-5xl mx-auto px-6 py-10 space-y-12">
|
||||||
|
|
||||||
|
<!-- Banner 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 new Banner</h2>
|
||||||
|
<form method="POST" class="space-y-4">
|
||||||
|
{{ banner_form.hidden_tag() }}
|
||||||
|
<div>
|
||||||
|
<label class="block mb-1 text-sm font-medium">{{ banner_form.content.label }}</label>
|
||||||
|
{{ banner_form.content(class="w-full rounded-lg p-3 bg-background border border-border text-text placeholder-muted focus:outline-none focus:ring-2 focus:ring-primary") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-primary hover:bg-blue-700 text-white py-2 rounded-md transition">
|
||||||
|
Add message
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% if error %}
|
||||||
|
<p class="text-red-500 text-center mt-3">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Banners -->
|
||||||
|
{% if banners %}
|
||||||
|
<section>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{% for banner in banners %}
|
||||||
|
<div 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">{{ banner.content }}</h3>
|
||||||
|
{% if banner.is_active %}
|
||||||
|
<form method="POST" action="{{ url_for('main.hide_active_banner') }}">
|
||||||
|
<button type="submit" class="w-full bg-red-600 hover:bg-red-700 text-white py-2 rounded-md transition">Hide</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST", action="{{ url_for('main.set_active_banner', banner_id=banner.id)}}">
|
||||||
|
<button type="submit" class="w-full bg-primary hover:bg-blue-700 text-white py-2 rounded-md transition">
|
||||||
|
Show banner
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center text-muted">No banners created yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -49,6 +49,14 @@
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="max-w-5xl mx-auto px-6 py-10 space-y-12">
|
<main class="max-w-5xl mx-auto px-6 py-10 space-y-12">
|
||||||
|
|
||||||
|
{% if active_banner %}
|
||||||
|
<section class="flex justify-center">
|
||||||
|
<div class="p-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-gray-800 dark:text-yellow-300" role="alert">
|
||||||
|
<span class="font-bold">{{ active_banner.content }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Password Form -->
|
<!-- Password Form -->
|
||||||
<section class="flex justify-center">
|
<section class="flex justify-center">
|
||||||
<div class="w-full max-w-md">
|
<div class="w-full max-w-md">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import cast
|
|||||||
from itsdangerous import BadSignature, URLSafeSerializer
|
from itsdangerous import BadSignature, URLSafeSerializer
|
||||||
from xkcdpass import xkcd_password
|
from xkcdpass import xkcd_password
|
||||||
|
|
||||||
from .models import Deck, Token, db
|
from .models import Deck, InfoBanner, Token, db
|
||||||
|
|
||||||
def init_password_generator():
|
def init_password_generator():
|
||||||
wordfile = xkcd_password.locate_wordfile()
|
wordfile = xkcd_password.locate_wordfile()
|
||||||
@@ -16,6 +16,9 @@ def init_serializer(app: Flask):
|
|||||||
global serializer
|
global serializer
|
||||||
serializer = URLSafeSerializer(app.config['SECRET_KEY'])
|
serializer = URLSafeSerializer(app.config['SECRET_KEY'])
|
||||||
|
|
||||||
|
def get_active_banner() -> InfoBanner | None:
|
||||||
|
return db.session.execute(db.select(InfoBanner).where(InfoBanner.is_active)).scalar_one_or_none()
|
||||||
|
|
||||||
def get_available_decks(app: Flask):
|
def get_available_decks(app: Flask):
|
||||||
slides_dir = app.config['SLIDES_DIR']
|
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 = [name for name in os.listdir(slides_dir) if os.path.isdir(os.path.join(slides_dir, name))]
|
||||||
|
|||||||
Reference in New Issue
Block a user