283a37638b
- Implement login and registration HTML templates for user authentication. - Create user management functions in user.py for handling user data, including adding, deleting, and updating users. - Introduce password hashing and strength checking for secure user credentials. - Add terms of service and privacy policy pages to the website. - Enhance main page with dynamic content based on user session status.
473 lines
17 KiB
Python
473 lines
17 KiB
Python
from flask_jwt_extended import JWTManager, create_access_token, jwt_required
|
|
from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session
|
|
import os
|
|
import json
|
|
import calendar
|
|
from datetime import timedelta, datetime, date
|
|
from pathlib import Path
|
|
from functools import wraps
|
|
from werkzeug.security import generate_password_hash, check_password_hash
|
|
import bleach
|
|
from markupsafe import escape
|
|
|
|
app = Flask(__name__)
|
|
app.secret_key = "ASDfhbsdfseiufhgildsrfrjg874368546987s6e8468f4s"
|
|
app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY", app.secret_key)
|
|
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=12)
|
|
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
|
app.config["SESSION_COOKIE_SAMESITE"] = "Strict"
|
|
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("SESSION_COOKIE_SECURE", "0") == "1"
|
|
app.config["PREFERRED_URL_SCHEME"] = "https" if os.environ.get("SESSION_COOKIE_SECURE") == "1" else "http"
|
|
jwt = JWTManager(app)
|
|
|
|
|
|
@app.after_request
|
|
def set_security_headers(response):
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; img-src 'self' data:; connect-src 'self';"
|
|
return response
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
DATA_DIR = BASE_DIR / "data"
|
|
USERS_FILE = DATA_DIR / "users.json"
|
|
APPOINTMENTS_FILE = DATA_DIR / "appointments.json"
|
|
POSTS_FILE = DATA_DIR / "posts.json"
|
|
|
|
|
|
def _issue_access_token() -> str:
|
|
return create_access_token(identity="license-validation-client")
|
|
|
|
|
|
def _ensure_data_files() -> None:
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
if not USERS_FILE.exists():
|
|
USERS_FILE.write_text("[]", encoding="utf-8")
|
|
if not APPOINTMENTS_FILE.exists():
|
|
APPOINTMENTS_FILE.write_text("[]", encoding="utf-8")
|
|
if not POSTS_FILE.exists():
|
|
POSTS_FILE.write_text("[]", encoding="utf-8")
|
|
|
|
|
|
def _read_json(file_path: Path) -> list:
|
|
_ensure_data_files()
|
|
try:
|
|
return json.loads(file_path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
return []
|
|
|
|
|
|
def _write_json(file_path: Path, payload: list) -> None:
|
|
_ensure_data_files()
|
|
file_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True), encoding="utf-8")
|
|
|
|
|
|
def _sanitize_text(text: str, max_length: int = 255) -> str:
|
|
"""Sanitize user text input: strip, limit length, and escape HTML."""
|
|
text = (text or "").strip()
|
|
if len(text) > max_length:
|
|
text = text[:max_length]
|
|
return text
|
|
|
|
|
|
def _sanitize_html(html_content: str, max_length: int = 50000) -> str:
|
|
"""Sanitize HTML content: allow safe tags only."""
|
|
if not html_content:
|
|
return ""
|
|
html_content = html_content[:max_length]
|
|
allowed_tags = ["p", "br", "strong", "em", "u", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "a", "blockquote", "code", "pre"]
|
|
allowed_attrs = {"a": ["href", "title"]}
|
|
return bleach.clean(html_content, tags=allowed_tags, attributes=allowed_attrs, strip=True)
|
|
|
|
|
|
def _validate_username(username: str) -> bool:
|
|
"""Validate username format: alphanumeric, underscore, dash, 3-30 chars."""
|
|
if not username or not isinstance(username, str):
|
|
return False
|
|
username = username.strip()
|
|
if len(username) < 3 or len(username) > 30:
|
|
return False
|
|
return all(c.isalnum() or c in "_-" for c in username)
|
|
|
|
|
|
def _find_user(username: str):
|
|
username_key = (username or "").strip().lower()
|
|
if not username_key:
|
|
return None
|
|
users = _read_json(USERS_FILE)
|
|
for entry in users:
|
|
if entry.get("username", "").lower() == username_key:
|
|
return entry
|
|
return None
|
|
|
|
|
|
def login_required(view_func):
|
|
@wraps(view_func)
|
|
def wrapped(*args, **kwargs):
|
|
if "username" not in session:
|
|
flash("Bitte zuerst einloggen.", "error")
|
|
return redirect(url_for("login"))
|
|
return view_func(*args, **kwargs)
|
|
|
|
return wrapped
|
|
|
|
|
|
def admin_required(view_func):
|
|
@wraps(view_func)
|
|
def wrapped(*args, **kwargs):
|
|
if "username" not in session:
|
|
flash("Bitte zuerst einloggen.", "error")
|
|
return redirect(url_for("login"))
|
|
|
|
current_user = _find_user(session.get("username"))
|
|
if not current_user or not current_user.get("is_admin", False):
|
|
flash("Zugriff verweigert. Admin-Rechte erforderlich.", "error")
|
|
return redirect(url_for("default"))
|
|
|
|
return view_func(*args, **kwargs)
|
|
|
|
return wrapped
|
|
|
|
|
|
"""-----------------------------------------------------------------------------------------------------------------"""
|
|
|
|
@app.route("/", methods=["GET", "POST"])
|
|
def default():
|
|
return render_template("main.html")
|
|
|
|
|
|
@app.route('/login', methods=['GET', 'POST'])
|
|
def login():
|
|
if 'username' in session:
|
|
return redirect(url_for('default'))
|
|
|
|
if request.method == 'POST':
|
|
data = request.get_json(silent=True) or {}
|
|
username = (data.get("username") or request.form.get('username') or "").strip()
|
|
password = data.get("password") or request.form.get('password') or ""
|
|
|
|
if not username or not password:
|
|
if request.is_json:
|
|
return jsonify({"error": "Missing login data"}), 400
|
|
flash('Bitte Benutzername und Passwort eingeben.', 'error')
|
|
return redirect(url_for('login'))
|
|
|
|
stored_user = _find_user(username)
|
|
|
|
if stored_user and check_password_hash(stored_user.get("password_hash", ""), password):
|
|
session['username'] = stored_user.get("username")
|
|
session['display_name'] = stored_user.get("display_name") or stored_user.get("username")
|
|
session['is_admin'] = stored_user.get("is_admin", False)
|
|
session['access_token'] = _issue_access_token()
|
|
|
|
if request.is_json:
|
|
return jsonify({"access_token": session['access_token'], "token_type": "Bearer"}), 200
|
|
|
|
return redirect(url_for('default'))
|
|
|
|
if request.is_json:
|
|
return jsonify({"error": "Invalid credentials"}), 401
|
|
flash('Login fehlgeschlagen. Bitte pruefen Sie Ihre Eingaben.', 'error')
|
|
get_flashed_messages()
|
|
|
|
return render_template('login.html')
|
|
|
|
|
|
@app.route("/register", methods=["GET", "POST"])
|
|
def register():
|
|
if request.method == "POST":
|
|
username = (request.form.get("username") or "").strip()
|
|
display_name = (request.form.get("display_name") or "").strip()
|
|
password = request.form.get("password") or ""
|
|
password_repeat = request.form.get("password_repeat") or ""
|
|
|
|
if not username or not display_name or not password or not password_repeat:
|
|
flash("Bitte alle Felder ausfuellen.", "error")
|
|
return redirect(url_for("register"))
|
|
|
|
if not _validate_username(username):
|
|
flash("Benutzername muss 3-30 Zeichen lang sein und nur Buchstaben, Zahlen, - und _ enthalten.", "error")
|
|
return redirect(url_for("register"))
|
|
|
|
if len(password) < 8:
|
|
flash("Das Passwort muss mindestens 8 Zeichen haben.", "error")
|
|
return redirect(url_for("register"))
|
|
|
|
if password != password_repeat:
|
|
flash("Die Passwoerter stimmen nicht ueberein.", "error")
|
|
return redirect(url_for("register"))
|
|
|
|
if _find_user(username):
|
|
flash("Benutzername bereits vergeben.", "error")
|
|
return redirect(url_for("register"))
|
|
|
|
display_name = _sanitize_text(display_name, 100)
|
|
|
|
users = _read_json(USERS_FILE)
|
|
is_admin = len(users) == 0
|
|
users.append(
|
|
{
|
|
"username": username,
|
|
"display_name": display_name,
|
|
"password_hash": generate_password_hash(password),
|
|
"is_admin": is_admin,
|
|
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
|
}
|
|
)
|
|
_write_json(USERS_FILE, users)
|
|
flash("Registrierung erfolgreich. Bitte jetzt einloggen.", "success")
|
|
return redirect(url_for("login"))
|
|
|
|
return render_template("register.html")
|
|
|
|
|
|
@app.route('/appointments', methods=['GET', 'POST'])
|
|
@login_required
|
|
def appointments():
|
|
today = date.today()
|
|
month = request.args.get("month", type=int) or today.month
|
|
year = request.args.get("year", type=int) or today.year
|
|
|
|
month = 1 if month < 1 else 12 if month > 12 else month
|
|
year = 1970 if year < 1970 else year
|
|
|
|
if request.method == 'POST':
|
|
selected_date = (request.form.get("selected_date") or "").strip()
|
|
appointment_time = (request.form.get("appointment_time") or "").strip()
|
|
subject = (request.form.get("subject") or "").strip()
|
|
note = (request.form.get("note") or "").strip()
|
|
|
|
try:
|
|
date.fromisoformat(selected_date)
|
|
except ValueError:
|
|
flash("Bitte einen gueltigen Termin im Kalender auswaehlen.", "error")
|
|
return redirect(url_for("appointments", month=month, year=year))
|
|
|
|
if not appointment_time or not subject:
|
|
flash("Bitte Uhrzeit und Betreff ausfuellen.", "error")
|
|
return redirect(url_for("appointments", month=month, year=year))
|
|
|
|
subject = _sanitize_text(subject, 200)
|
|
note = _sanitize_text(note, 2000)
|
|
|
|
entries = _read_json(APPOINTMENTS_FILE)
|
|
entries.append(
|
|
{
|
|
"id": f"a-{int(datetime.utcnow().timestamp() * 1000)}",
|
|
"username": session.get("username"),
|
|
"display_name": session.get("display_name"),
|
|
"date": selected_date,
|
|
"time": appointment_time,
|
|
"subject": subject,
|
|
"note": note,
|
|
"status": "Angefragt",
|
|
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
|
}
|
|
)
|
|
_write_json(APPOINTMENTS_FILE, entries)
|
|
|
|
flash("Termin erfolgreich angefragt.", "success")
|
|
selected = date.fromisoformat(selected_date)
|
|
return redirect(url_for("appointments", month=selected.month, year=selected.year))
|
|
|
|
cal = calendar.Calendar(firstweekday=0)
|
|
month_grid = cal.monthdayscalendar(year, month)
|
|
month_name = calendar.month_name[month]
|
|
|
|
previous_month = month - 1
|
|
previous_year = year
|
|
if previous_month == 0:
|
|
previous_month = 12
|
|
previous_year -= 1
|
|
|
|
next_month = month + 1
|
|
next_year = year
|
|
if next_month == 13:
|
|
next_month = 1
|
|
next_year += 1
|
|
|
|
all_appointments = _read_json(APPOINTMENTS_FILE)
|
|
user_appointments = [item for item in all_appointments if item.get("username") == session.get("username")]
|
|
user_appointments.sort(key=lambda item: (item.get("date", ""), item.get("time", "")), reverse=False)
|
|
|
|
return render_template(
|
|
"appointments.html",
|
|
month=month,
|
|
month_name=month_name,
|
|
year=year,
|
|
month_grid=month_grid,
|
|
today_iso=today.isoformat(),
|
|
previous_month=previous_month,
|
|
previous_year=previous_year,
|
|
next_month=next_month,
|
|
next_year=next_year,
|
|
user_appointments=user_appointments,
|
|
)
|
|
|
|
|
|
@app.route('/admin/dashboard')
|
|
@admin_required
|
|
def admin_dashboard():
|
|
all_appointments = _read_json(APPOINTMENTS_FILE)
|
|
all_appointments.sort(key=lambda x: (x.get("date", ""), x.get("time", "")), reverse=True)
|
|
|
|
status_counts = {
|
|
"Angefragt": len([a for a in all_appointments if a.get("status") == "Angefragt"]),
|
|
"Bestaetigt": len([a for a in all_appointments if a.get("status") == "Bestaetigt"]),
|
|
"Abgelehnt": len([a for a in all_appointments if a.get("status") == "Abgelehnt"]),
|
|
}
|
|
|
|
posts = _read_json(POSTS_FILE)
|
|
total_posts = len(posts)
|
|
|
|
return render_template(
|
|
"admin_dashboard.html",
|
|
appointments=all_appointments,
|
|
status_counts=status_counts,
|
|
total_posts=total_posts,
|
|
)
|
|
|
|
|
|
@app.route('/admin/appointment/<appointment_id>', methods=['POST'])
|
|
@admin_required
|
|
def update_appointment(appointment_id):
|
|
action = request.form.get("action", "").strip()
|
|
response_text = _sanitize_text(request.form.get("response") or "", 5000)
|
|
|
|
if action not in ["confirm", "reject"]:
|
|
flash("Ungueltige Aktion.", "error")
|
|
return redirect(url_for("admin_dashboard"))
|
|
|
|
all_appointments = _read_json(APPOINTMENTS_FILE)
|
|
appointment = None
|
|
for item in all_appointments:
|
|
if item.get("id") == appointment_id:
|
|
appointment = item
|
|
break
|
|
|
|
if not appointment:
|
|
flash("Termin nicht gefunden.", "error")
|
|
return redirect(url_for("admin_dashboard"))
|
|
|
|
if action == "confirm":
|
|
appointment["status"] = "Bestaetigt"
|
|
else:
|
|
appointment["status"] = "Abgelehnt"
|
|
|
|
appointment["response"] = response_text
|
|
appointment["responded_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
|
appointment["responded_by"] = session.get("username")
|
|
|
|
_write_json(APPOINTMENTS_FILE, all_appointments)
|
|
flash(f"Termin wurde {('bestaetigt' if action == 'confirm' else 'abgelehnt')}.", "success")
|
|
return redirect(url_for("admin_dashboard"))
|
|
|
|
|
|
@app.route('/admin/blog', methods=['GET', 'POST'])
|
|
@admin_required
|
|
def admin_blog():
|
|
if request.method == 'POST':
|
|
action = request.form.get("action", "").strip()
|
|
|
|
if action == "create":
|
|
title = _sanitize_text(request.form.get("title") or "", 200)
|
|
content = _sanitize_html(request.form.get("content") or "", 50000)
|
|
excerpt = _sanitize_text(request.form.get("excerpt") or "", 500)
|
|
|
|
if not title or not content:
|
|
flash("Bitte Titel und inhalt ausfuellen.", "error")
|
|
return redirect(url_for("admin_blog"))
|
|
|
|
posts = _read_json(POSTS_FILE)
|
|
posts.append({
|
|
"id": f"p-{int(datetime.utcnow().timestamp() * 1000)}",
|
|
"title": title,
|
|
"excerpt": excerpt or (content[:150] + "...") if len(content) > 150 else content,
|
|
"content": content,
|
|
"author": session.get("username"),
|
|
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
|
"published": True,
|
|
})
|
|
_write_json(POSTS_FILE, posts)
|
|
flash("Beitrag veroeffentlicht.", "success")
|
|
return redirect(url_for("admin_blog"))
|
|
|
|
elif action == "delete":
|
|
post_id = (request.form.get("post_id") or "").strip()
|
|
if not post_id or not post_id.startswith("p-"):
|
|
flash("Ungueltige Beitrag-ID.", "error")
|
|
return redirect(url_for("admin_blog"))
|
|
posts = _read_json(POSTS_FILE)
|
|
original_count = len(posts)
|
|
posts = [p for p in posts if p.get("id") != post_id]
|
|
if len(posts) == original_count:
|
|
flash("Beitrag nicht gefunden.", "error")
|
|
return redirect(url_for("admin_blog"))
|
|
_write_json(POSTS_FILE, posts)
|
|
flash("Beitrag geloescht.", "success")
|
|
return redirect(url_for("admin_blog"))
|
|
|
|
posts = _read_json(POSTS_FILE)
|
|
posts.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
|
return render_template("admin_blog.html", posts=posts)
|
|
|
|
|
|
@app.route('/blog')
|
|
def blog():
|
|
posts = _read_json(POSTS_FILE)
|
|
posts.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
|
return render_template("blog.html", posts=posts)
|
|
|
|
|
|
@app.route('/blog/<post_id>')
|
|
def blog_post(post_id):
|
|
if not post_id or not post_id.startswith("p-"):
|
|
flash("Ungueltige Beitrag-ID.", "error")
|
|
return redirect(url_for("blog"))
|
|
|
|
posts = _read_json(POSTS_FILE)
|
|
post = None
|
|
for p in posts:
|
|
if p.get("id") == post_id:
|
|
post = p
|
|
break
|
|
|
|
if not post:
|
|
flash("Beitrag nicht gefunden.", "error")
|
|
return redirect(url_for("blog"))
|
|
|
|
return render_template("blog_post.html", post=post)
|
|
|
|
|
|
@app.route('/datenschutz')
|
|
def datenschutz():
|
|
return render_template("datenschutz.html")
|
|
|
|
|
|
@app.route('/impressum')
|
|
def impressum():
|
|
return render_template("impressum.html")
|
|
|
|
|
|
@app.route('/nutzungsbedingungen')
|
|
def nutzungsbedingungen():
|
|
return render_template("nutzungsbedingungen.html")
|
|
|
|
|
|
@app.route('/logout')
|
|
def logout():
|
|
session.pop('username', None)
|
|
session.pop('display_name', None)
|
|
session.pop('is_admin', None)
|
|
session.pop('access_token', None)
|
|
flash('Logged out successfully', 'info')
|
|
return redirect(url_for('login'))
|
|
|
|
|
|
def main():
|
|
app.run(host="0.0.0.0", port=4999, debug=False)
|
|
|
|
if __name__ == "__main__":
|
|
main() |