style: Enhance tutorial video management with URL normalization and improved preview functionality

This commit is contained in:
2026-05-15 21:37:09 +02:00
parent 8f6d5dedcf
commit f65188d596
3 changed files with 365 additions and 9 deletions
+91 -8
View File
@@ -13,6 +13,7 @@ import threading
from datetime import timedelta, datetime, date from datetime import timedelta, datetime, date
from functools import wraps from functools import wraps
from io import BytesIO from io import BytesIO
from urllib.parse import parse_qs, quote, urlparse
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import bleach import bleach
@@ -36,7 +37,8 @@ def set_security_headers(response):
response.headers["X-Frame-Options"] = "SAMEORIGIN" response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" 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';" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
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'; frame-src 'self' https://www.youtube.com https://youtube.com https://www.youtube-nocookie.com https://youtube-nocookie.com; media-src 'self' https:;"
return response return response
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -186,6 +188,50 @@ def _sanitize_text(text: str, max_length: int = 255) -> str:
return text return text
def _normalize_tutorial_video_url(url: str, origin: str = "") -> str:
"""Normalize user-provided tutorial URLs to safe embed URLs where possible."""
raw = _sanitize_text(url, 800)
if not raw:
return ""
if raw.startswith("/"):
return raw
try:
parsed = urlparse(raw)
except Exception:
return ""
host = (parsed.netloc or "").lower()
path = parsed.path or ""
video_id = ""
if host in {"youtu.be", "www.youtu.be"}:
video_id = path.strip("/").split("/")[0]
elif "youtube.com" in host:
if path.startswith("/watch"):
video_id = (parse_qs(parsed.query).get("v") or [""])[0]
elif path.startswith("/embed/"):
video_id = path.split("/embed/", 1)[1].split("/")[0]
elif path.startswith("/shorts/"):
video_id = path.split("/shorts/", 1)[1].split("/")[0]
elif "youtube-nocookie.com" in host and path.startswith("/embed/"):
video_id = path.split("/embed/", 1)[1].split("/")[0]
if video_id and re.fullmatch(r"[A-Za-z0-9_-]{6,20}", video_id):
origin_value = quote((origin or "").rstrip("/"), safe=":/")
if origin_value:
return (
f"https://www.youtube-nocookie.com/embed/{video_id}"
f"?rel=0&modestbranding=1&playsinline=1&origin={origin_value}"
)
return f"https://www.youtube-nocookie.com/embed/{video_id}?rel=0&modestbranding=1&playsinline=1"
if parsed.scheme in {"http", "https"} and host:
return raw
return ""
def _slugify_subdomain(value: str) -> str: def _slugify_subdomain(value: str) -> str:
cleaned = (value or "").strip().lower() cleaned = (value or "").strip().lower()
cleaned = cleaned.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") cleaned = cleaned.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
@@ -2399,9 +2445,24 @@ def my_instance_management():
def my_tutorials(): def my_tutorials():
"""Display tutorial videos for the logged-in user.""" """Display tutorial videos for the logged-in user."""
client = None client = None
origin = request.url_root.rstrip("/")
try: try:
client, col = _get_collection("tutorials") client, col = _get_collection("tutorials")
tutorials = list(col.find({"published": True}, {"_id": 1, "title": 1, "description": 1, "video_url": 1, "thumbnail_url": 1, "duration": 1, "category": 1, "created_at": 1}).sort("created_at", -1)) tutorials = list(
col.find(
{"published": True},
{
"_id": 1,
"title": 1,
"description": 1,
"video_url": 1,
"thumbnail_url": 1,
"duration": 1,
"category": 1,
"created_at": 1,
},
).sort("created_at", -1)
)
except PyMongoError: except PyMongoError:
flash("Tutorials konnten nicht geladen werden.", "error") flash("Tutorials konnten nicht geladen werden.", "error")
tutorials = [] tutorials = []
@@ -2415,7 +2476,7 @@ def my_tutorials():
"_id": "1", "_id": "1",
"title": "Erste Schritte mit Invario", "title": "Erste Schritte mit Invario",
"description": "Lernen Sie die Grundlagen von Invario und wie Sie Ihre erste Instanz einrichten.", "description": "Lernen Sie die Grundlagen von Invario und wie Sie Ihre erste Instanz einrichten.",
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", "video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"thumbnail_url": "/static/images/tutorial-thumb-1.jpg", "thumbnail_url": "/static/images/tutorial-thumb-1.jpg",
"duration": "12:34", "duration": "12:34",
"category": "Anfänger" "category": "Anfänger"
@@ -2424,7 +2485,7 @@ def my_tutorials():
"_id": "2", "_id": "2",
"title": "Inventarverwaltung", "title": "Inventarverwaltung",
"description": "Alles über die Verwaltung Ihres Inventars und der Ausleihvorgänge.", "description": "Alles über die Verwaltung Ihres Inventars und der Ausleihvorgänge.",
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", "video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"thumbnail_url": "/static/images/tutorial-thumb-2.jpg", "thumbnail_url": "/static/images/tutorial-thumb-2.jpg",
"duration": "18:45", "duration": "18:45",
"category": "Funktionen" "category": "Funktionen"
@@ -2433,12 +2494,21 @@ def my_tutorials():
"_id": "3", "_id": "3",
"title": "Admin-Funktionen", "title": "Admin-Funktionen",
"description": "Erweiterte Administratorfunktionen und Systemverwaltung.", "description": "Erweiterte Administratorfunktionen und Systemverwaltung.",
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", "video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"thumbnail_url": "/static/images/tutorial-thumb-3.jpg", "thumbnail_url": "/static/images/tutorial-thumb-3.jpg",
"duration": "22:15", "duration": "22:15",
"category": "Admin" "category": "Admin"
} }
] ]
normalized_tutorials = []
for item in tutorials:
normalized = dict(item)
normalized["video_url"] = _normalize_tutorial_video_url(item.get("video_url") or "", origin)
if normalized.get("video_url"):
normalized_tutorials.append(normalized)
tutorials = normalized_tutorials
return render_template("my_tutorials.html", tutorials=tutorials) return render_template("my_tutorials.html", tutorials=tutorials)
@@ -2452,8 +2522,21 @@ def append_video():
duration = _sanitize_text(request.form.get("duration") or "", 20) duration = _sanitize_text(request.form.get("duration") or "", 20)
category = _sanitize_text(request.form.get("category") or "", 50) category = _sanitize_text(request.form.get("category") or "", 50)
if not title or not video_url: if not title:
flash("Bitte Titel und Video-URL angeben.", "error") flash("Bitte einen Titel für das Tutorial eingeben.", "error")
return redirect(url_for("admin_blog"))
if not video_url:
flash("Bitte eine Video-URL eingeben.", "error")
return redirect(url_for("admin_blog"))
normalized_video_url = _normalize_tutorial_video_url(video_url, request.url_root.rstrip("/"))
if not normalized_video_url:
flash(
"Ungültige Video-URL. Erlaubt sind YouTube-Links im Format watch?v=..., youtu.be/..., shorts/... oder embed/...",
"error",
)
return redirect(url_for("admin_blog")) return redirect(url_for("admin_blog"))
client = None client = None
@@ -2463,7 +2546,7 @@ def append_video():
{ {
"title": title, "title": title,
"description": description, "description": description,
"video_url": video_url, "video_url": normalized_video_url,
"thumbnail_url": thumbnail_url, "thumbnail_url": thumbnail_url,
"duration": duration, "duration": duration,
"category": category, "category": category,
+248
View File
@@ -28,6 +28,84 @@
margin: 0 0 0.9rem; margin: 0 0 0.9rem;
} }
.section-separator {
margin: 1.2rem 0;
border: 0;
border-top: 1px solid #dbe5ec;
}
.field-hint {
margin-top: 0.35rem;
color: #47657b;
font-size: 0.82rem;
line-height: 1.45;
}
.preview-status {
margin-top: 0.55rem;
padding: 0.5rem 0.62rem;
border-radius: 8px;
font-size: 0.82rem;
border: 1px solid #c8d7e3;
background: #f3f8fc;
color: #2a4f68;
}
.preview-status.success {
border-color: #b8d9c6;
background: #eef8f2;
color: #255b3f;
}
.preview-status.error {
border-color: #e3c2c2;
background: #fcf2f2;
color: #7b2e2e;
}
.preview-panel {
margin-top: 0.75rem;
border: 1px solid #d1dde7;
border-radius: 12px;
overflow: hidden;
background: #0e1c27;
}
.preview-frame-wrap {
position: relative;
width: 100%;
padding-bottom: 56.25%;
height: 0;
}
.preview-frame-wrap iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.preview-empty {
display: grid;
place-items: center;
padding: 1rem;
min-height: 96px;
background: #f5f8fb;
color: #47657b;
font-size: 0.85rem;
border-top: 1px solid #d1dde7;
}
.hint-list {
margin: 0.35rem 0 0;
padding-left: 1.1rem;
}
.hint-list li {
margin: 0.2rem 0;
}
.field { .field {
margin-bottom: 0.9rem; margin-bottom: 0.9rem;
} }
@@ -174,6 +252,95 @@
<input type="hidden" name="action" value="create" /> <input type="hidden" name="action" value="create" />
<button type="submit" class="submit-btn">Veröffentlichen</button> <button type="submit" class="submit-btn">Veröffentlichen</button>
</form> </form>
<hr class="section-separator" />
<h2>Tutorial-Video hinzufügen</h2>
<form method="POST" action="{{ url_for('append_video') }}">
<div class="field">
<label for="tutorial_title">Titel</label>
<input
id="tutorial_title"
name="title"
type="text"
placeholder="z. B. Erste Schritte"
required
/>
</div>
<div class="field">
<label for="tutorial_video_url">Video-URL</label>
<input
id="tutorial_video_url"
name="video_url"
type="url"
placeholder="https://www.youtube.com/watch?v=..."
required
/>
<div class="field-hint">
Erlaubte Formate:
<ul class="hint-list">
<li>https://www.youtube.com/watch?v=VIDEO_ID</li>
<li>https://youtu.be/VIDEO_ID</li>
<li>https://www.youtube.com/shorts/VIDEO_ID</li>
<li>https://www.youtube.com/embed/VIDEO_ID</li>
</ul>
</div>
<div id="preview-status" class="preview-status" aria-live="polite">
Vorschau wird angezeigt, sobald eine gueltige YouTube-URL erkannt wird.
</div>
<div class="preview-panel">
<div class="preview-frame-wrap" id="preview-frame-wrap" hidden>
<iframe
id="tutorial-video-preview"
src=""
title="Live Vorschau Tutorial-Video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>
<div id="preview-empty" class="preview-empty">
Noch keine Vorschau verfuegbar.
</div>
</div>
</div>
<div class="field">
<label for="tutorial_description">Beschreibung</label>
<textarea
id="tutorial_description"
name="description"
placeholder="Kurze Beschreibung des Videos"
></textarea>
</div>
<div class="field">
<label for="tutorial_thumbnail">Thumbnail-URL (optional)</label>
<input
id="tutorial_thumbnail"
name="thumbnail_url"
type="url"
placeholder="https://..."
/>
</div>
<div class="field">
<label for="tutorial_duration">Dauer (optional)</label>
<input
id="tutorial_duration"
name="duration"
type="text"
placeholder="z. B. 12:34"
/>
</div>
<div class="field">
<label for="tutorial_category">Kategorie (optional)</label>
<input
id="tutorial_category"
name="category"
type="text"
placeholder="z. B. Anfänger"
/>
</div>
<button type="submit" class="submit-btn">Video hinzufügen</button>
</form>
</aside> </aside>
<article> <article>
@@ -203,4 +370,85 @@
</section> </section>
</article> </article>
</section> </section>
<script>
(function () {
const input = document.getElementById("tutorial_video_url");
const status = document.getElementById("preview-status");
const frameWrap = document.getElementById("preview-frame-wrap");
const frame = document.getElementById("tutorial-video-preview");
const empty = document.getElementById("preview-empty");
function extractYoutubeId(rawUrl) {
let parsed;
try {
parsed = new URL((rawUrl || "").trim());
} catch {
return "";
}
const host = parsed.hostname.toLowerCase();
const path = parsed.pathname;
if (host === "youtu.be" || host === "www.youtu.be") {
return path.replace(/^\//, "").split("/")[0] || "";
}
if (host.includes("youtube.com")) {
if (path === "/watch") {
return parsed.searchParams.get("v") || "";
}
if (path.startsWith("/shorts/")) {
return path.split("/shorts/")[1].split("/")[0] || "";
}
if (path.startsWith("/embed/")) {
return path.split("/embed/")[1].split("/")[0] || "";
}
}
if (host.includes("youtube-nocookie.com") && path.startsWith("/embed/")) {
return path.split("/embed/")[1].split("/")[0] || "";
}
return "";
}
function toEmbedUrl(videoId) {
const origin = encodeURIComponent(window.location.origin);
return `https://www.youtube-nocookie.com/embed/${videoId}?rel=0&modestbranding=1&playsinline=1&origin=${origin}`;
}
function updatePreview() {
const videoId = extractYoutubeId(input.value);
const validId = /^[A-Za-z0-9_-]{6,20}$/.test(videoId);
if (!input.value.trim()) {
frame.src = "";
frameWrap.hidden = true;
empty.hidden = false;
status.className = "preview-status";
status.textContent = "Vorschau wird angezeigt, sobald eine gueltige YouTube-URL erkannt wird.";
return;
}
if (!validId) {
frame.src = "";
frameWrap.hidden = true;
empty.hidden = false;
status.className = "preview-status error";
status.textContent = "Keine gueltige YouTube-URL erkannt.";
return;
}
frame.src = toEmbedUrl(videoId);
frameWrap.hidden = false;
empty.hidden = true;
status.className = "preview-status success";
status.textContent = "Gueltige URL erkannt. Live-Vorschau aktiv.";
}
input.addEventListener("input", updatePreview);
input.addEventListener("change", updatePreview);
})();
</script>
{% endblock %} {% endblock %}
+26 -1
View File
@@ -306,6 +306,7 @@
title="{{ tutorials[0].title }}" title="{{ tutorials[0].title }}"
frameborder="0" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen> allowfullscreen>
</iframe> </iframe>
</div> </div>
@@ -343,7 +344,14 @@
</h3> </h3>
<div class="tutorials-grid"> <div class="tutorials-grid">
{% for tutorial in tutorials %} {% for tutorial in tutorials %}
<div class="tutorial-card" onclick="loadVideo('{{ tutorial.video_url }}', '{{ tutorial.title|safe }}', '{{ tutorial.description|safe }}', '{{ tutorial.category|default('Allgemein') }}', '{{ tutorial.duration|default('') }}')"> <div
class="tutorial-card"
data-video-url="{{ tutorial.video_url|e }}"
data-title="{{ tutorial.title|e }}"
data-description="{{ tutorial.description|default('', true)|e }}"
data-category="{{ tutorial.category|default('Allgemein', true)|e }}"
data-duration="{{ tutorial.duration|default('', true)|e }}"
>
<div class="tutorial-thumbnail"> <div class="tutorial-thumbnail">
{% if tutorial.thumbnail_url %} {% if tutorial.thumbnail_url %}
<img src="{{ tutorial.thumbnail_url }}" alt="{{ tutorial.title }}" /> <img src="{{ tutorial.thumbnail_url }}" alt="{{ tutorial.title }}" />
@@ -370,6 +378,11 @@
const videoFrame = document.getElementById('videoFrame'); const videoFrame = document.getElementById('videoFrame');
const videoTitle = document.getElementById('videoTitle'); const videoTitle = document.getElementById('videoTitle');
const videoDescription = document.getElementById('videoDescription'); const videoDescription = document.getElementById('videoDescription');
if (!videoUrl) {
alert('Dieses Video kann aktuell nicht geladen werden.');
return;
}
videoFrame.src = videoUrl; videoFrame.src = videoUrl;
videoTitle.textContent = title; videoTitle.textContent = title;
@@ -398,6 +411,18 @@
const title = document.getElementById('videoTitle').textContent; const title = document.getElementById('videoTitle').textContent;
window.location.href = `{{ url_for('user_tickets') }}?subject=Tutorial-Feedback: ${encodeURIComponent(title)}`; window.location.href = `{{ url_for('user_tickets') }}?subject=Tutorial-Feedback: ${encodeURIComponent(title)}`;
} }
document.querySelectorAll('.tutorial-card').forEach((card) => {
card.addEventListener('click', () => {
loadVideo(
card.dataset.videoUrl || '',
card.dataset.title || '',
card.dataset.description || '',
card.dataset.category || 'Allgemein',
card.dataset.duration || ''
);
});
});
</script> </script>
{% else %} {% else %}