Compare commits

..

3 Commits

Author SHA1 Message Date
f6010b065d Merge remote-tracking branch 'origin/development' 2026-02-28 19:08:37 +00:00
8efca53e8e improve gallery 2026-02-28 19:07:28 +00:00
68c15fb367 improve security 2026-02-28 09:08:48 +00:00
16 changed files with 632 additions and 140 deletions

127
app.py
View File

@ -1,5 +1,7 @@
import os
import sqlite3
import secrets
import time
# Use eventlet only in production; keep dev on threading to avoid monkey_patch issues with reloader
FLASK_ENV = os.environ.get('FLASK_ENV', 'production')
@ -15,7 +17,7 @@ import mimetypes
from datetime import datetime, date, timedelta
import diskcache
import threading
from flask_socketio import SocketIO, emit
from flask_socketio import SocketIO, emit, disconnect
import geoip2.database
from functools import lru_cache
from urllib.parse import urlparse, unquote
@ -35,7 +37,7 @@ import helperfunctions as hf
import search_db_analyzer as sdb
import fnmatch
import openpyxl
from collections import OrderedDict
from collections import OrderedDict, defaultdict, deque
app_config = auth.return_app_config()
BASE_DIR = os.path.realpath(app_config['BASE_DIR'])
@ -50,6 +52,76 @@ _logged_request_ids = OrderedDict()
_logged_request_ids_lock = threading.Lock()
_LOGGED_REQUEST_IDS_MAX = 2048
_csrf_exempt_paths = (
'/socket.io/',
)
_rate_limit_lock = threading.Lock()
_rate_limit_buckets = defaultdict(deque)
_rate_limit_rules = {
'searchcommand': (60, 20),
'create_message': (60, 10),
'update_message': (60, 20),
'delete_message': (60, 10),
'create_calendar_entry': (60, 20),
'update_calendar_entry': (60, 20),
'delete_calendar_entry': (60, 20),
'import_calendar': (300, 3),
'folder_secret_config_action': (60, 20),
'search_db_query': (60, 20),
'remove_secret': (60, 20),
'remove_token': (60, 20),
}
_default_mutating_rate_limit = (60, 60)
def _ensure_csrf_token() -> str:
token = session.get('_csrf_token')
if not token:
token = secrets.token_urlsafe(32)
session['_csrf_token'] = token
return token
def _is_csrf_exempt() -> bool:
return any(request.path.startswith(prefix) for prefix in _csrf_exempt_paths)
def _client_identifier() -> str:
return request.remote_addr or 'unknown'
def _check_rate_limit():
# Rate-limit only mutating requests.
if request.method in {'GET', 'HEAD', 'OPTIONS'}:
return None
if _is_csrf_exempt():
return None
endpoint = request.endpoint or request.path
window_seconds, max_requests = _rate_limit_rules.get(endpoint, _default_mutating_rate_limit)
if max_requests <= 0:
return None
now = time.time()
bucket_key = (_client_identifier(), endpoint)
with _rate_limit_lock:
bucket = _rate_limit_buckets[bucket_key]
cutoff = now - window_seconds
while bucket and bucket[0] <= cutoff:
bucket.popleft()
if len(bucket) >= max_requests:
retry_after = max(1, int(bucket[0] + window_seconds - now))
response = jsonify({'error': 'Rate limit exceeded. Please retry later.'})
response.status_code = 429
response.headers['Retry-After'] = str(retry_after)
return response
bucket.append(now)
return None
def _is_duplicate_request(req_id: str) -> bool:
if not req_id:
@ -76,6 +148,29 @@ if FLASK_ENV == 'production':
app.config['SESSION_COOKIE_SAMESITE'] = 'None'
app.config['SESSION_COOKIE_SECURE'] = True
@app.before_request
def security_guard():
# Keep a CSRF token in every session so templates/JS can use it.
_ensure_csrf_token()
# Simple in-memory rate-limiter for mutating endpoints.
rate_limited = _check_rate_limit()
if rate_limited:
return rate_limited
# Enforce CSRF on mutating requests except explicitly exempted paths.
if request.method in {'POST', 'PUT', 'PATCH', 'DELETE'} and not _is_csrf_exempt():
sent_token = request.headers.get('X-CSRF-Token') or request.form.get('_csrf_token')
expected_token = session.get('_csrf_token')
if not sent_token or not expected_token or not secrets.compare_digest(sent_token, expected_token):
return jsonify({'error': 'CSRF validation failed'}), 403
@app.context_processor
def inject_csrf_token():
return {'csrf_token': _ensure_csrf_token()}
app.add_url_rule('/dashboard', view_func=auth.require_admin(a.dashboard))
app.add_url_rule('/file_access', view_func=auth.require_admin(a.file_access))
app.add_url_rule('/connections', view_func=auth.require_admin(a.connections))
@ -1154,11 +1249,21 @@ def media_exif(subpath):
@app.route("/transcript/<path:subpath>")
@auth.require_secret
def get_transcript(subpath):
root, *relative_parts = subpath.split('/')
base_path = session['folders'][root]
base_path = session.get('folders', {}).get(root)
if not base_path:
return "Transcription not found", 404
full_path = os.path.join(base_path, *relative_parts)
try:
full_path = check_path(full_path)
except (ValueError, PermissionError):
return "Transcription not found", 404
if not str(full_path).lower().endswith('.md'):
return "Transcription not found", 404
if not os.path.isfile(full_path):
return "Transcription not found", 404
@ -1172,7 +1277,7 @@ def get_transcript(subpath):
def create_token(subpath):
scheme = request.scheme # current scheme (http or https)
host = request.host
if 'admin' not in session and not session.get('admin'):
if not session.get('admin', False):
return "Unauthorized", 403
folder_config = auth.return_folder_config()
@ -1289,6 +1394,9 @@ def query_recent_connections():
@socketio.on('connect')
def handle_connect(auth=None):
global clients_connected, background_thread_running
if not session.get('admin', False):
return False
clients_connected += 1
print("Client connected. Total clients:", clients_connected)
with thread_lock:
@ -1299,11 +1407,16 @@ def handle_connect(auth=None):
@socketio.on('disconnect')
def handle_disconnect():
global clients_connected
if clients_connected > 0:
clients_connected -= 1
print("Client disconnected. Total clients:", clients_connected)
@socketio.on('request_initial_data')
def handle_request_initial_data():
if not session.get('admin', False):
disconnect()
return
rows = a.return_file_access()
connections = [
{
@ -1323,6 +1436,10 @@ def handle_request_initial_data():
@socketio.on('request_map_data')
def handle_request_map_data():
"""Send initial map data from in-memory log to avoid hitting the DB."""
if not session.get('admin', False):
disconnect()
return
rows = a.return_file_access()
connections = []
for row in rows:

View File

@ -75,9 +75,9 @@ def require_secret(f):
args_token = request.args.get('token')
args_dltoken = request.args.get('dltoken')
# 1b) immediately return if dltoken is provided
if args_dltoken:
if is_valid_token(args_dltoken):
# 1b) dltoken auth is only valid for /media access.
# Token validation and file authorization are handled in serve_file.
if args_dltoken and request.endpoint == 'serve_file' and request.path.startswith('/media/'):
return f(*args, **kwargs)
# 2) Initialize 'valid_secrets' in the session if missing

View File

@ -23,12 +23,44 @@ FILETYPE_GROUPS = {
ALL_GROUP_EXTS = tuple(sorted({ext for group in FILETYPE_GROUPS.values() for ext in group}))
ALL_GROUP_KEYS = set(FILETYPE_GROUPS.keys())
MAX_QUERY_LENGTH = 200
MAX_CATEGORY_LENGTH = 80
MAX_WORDS = 8
MAX_SCAN_RESULTS = 500
MAX_SCAN_RESULTS_WITH_TRANSCRIPT = 200
def _escape_like(value: str) -> str:
"""Escape SQL LIKE wildcards so user input is treated as text, not pattern syntax."""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _is_valid_iso_date(value: str) -> bool:
try:
datetime.strptime(value, "%Y-%m-%d")
return True
except ValueError:
return False
def searchcommand():
query = request.form.get("query", "").strip()
category = request.form.get("category", "").strip()
searchfolder = request.form.get("folder", "").strip()
datefrom = request.form.get("datefrom", "").strip()
dateto = request.form.get("dateto", "").strip()
if len(query) > MAX_QUERY_LENGTH:
return jsonify(error=f"Query too long (max {MAX_QUERY_LENGTH} characters)"), 400
if len(category) > MAX_CATEGORY_LENGTH:
return jsonify(error=f"Category too long (max {MAX_CATEGORY_LENGTH} characters)"), 400
if datefrom and not _is_valid_iso_date(datefrom):
return jsonify(error="Invalid datefrom format (expected YYYY-MM-DD)"), 400
if dateto and not _is_valid_iso_date(dateto):
return jsonify(error="Invalid dateto format (expected YYYY-MM-DD)"), 400
if datefrom and dateto and datefrom > dateto:
return jsonify(error="datefrom must be <= dateto"), 400
filetypes = [ft.strip().lower() for ft in request.form.getlist("filetype") if ft.strip()]
if not filetypes:
# Default to audio when nothing selected
@ -36,6 +68,9 @@ def searchcommand():
include_transcript = request.form.get("includeTranscript") in ["true", "on"]
words = [w for w in query.split() if w]
if len(words) > MAX_WORDS:
return jsonify(error=f"Too many search words (max {MAX_WORDS})"), 400
cursor = search_db.cursor()
# Determine allowed basefolders
@ -54,15 +89,16 @@ def searchcommand():
fields = ['relative_path', 'filename']
for word in words:
field_clauses = [f"{f} LIKE ?" for f in fields]
escaped_word = _escape_like(word)
field_clauses = [f"{f} LIKE ? ESCAPE '\\'" for f in fields]
conditions.append(f"({ ' OR '.join(field_clauses) })")
for _ in fields:
params.append(f"%{word}%")
params.append(f"%{escaped_word}%")
# Category filter
if category:
conditions.append("filename LIKE ?")
params.append(f"%{category}%")
conditions.append("filename LIKE ? ESCAPE '\\'")
params.append(f"%{_escape_like(category)}%")
# Basefolder filter
if allowed_basefolders:
@ -107,12 +143,17 @@ def searchcommand():
conditions.append("(" + " OR ".join(clauses) + ")")
# Build and execute SQL
sql = "SELECT * FROM files"
where_sql = ""
if conditions:
sql += " WHERE " + " AND ".join(conditions)
cursor.execute(sql, params)
where_sql = " WHERE " + " AND ".join(conditions)
count_sql = "SELECT COUNT(*) FROM files" + where_sql
total_results = cursor.execute(count_sql, params).fetchone()[0]
scan_limit = MAX_SCAN_RESULTS_WITH_TRANSCRIPT if include_transcript else MAX_SCAN_RESULTS
sql = "SELECT * FROM files" + where_sql + " LIMIT ?"
cursor.execute(sql, params + [scan_limit])
raw_results = cursor.fetchall()
total_results = len(raw_results)
# Process results
results = []

View File

@ -623,6 +623,14 @@ footer .audio-player-container {
border-radius: 12px;
background: linear-gradient(145deg, #ffffff, #f6f8fc);
box-shadow: var(--card-shadow);
aspect-ratio: 1 / 1;
}
.image-item .image-link {
display: block;
width: 100%;
height: 100%;
line-height: 0;
}
/* the filename overlay, centered at bottom */
@ -651,11 +659,12 @@ footer .audio-player-container {
.thumbnail {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.thumbnail-fallback {
object-fit: contain;
object-fit: cover;
background: #e5e5e5;
}

View File

@ -78,6 +78,33 @@ function encodeSubpath(subpath) {
.join('/');
}
function safeHtml(value) {
if (window.escapeHtml) return window.escapeHtml(value);
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function sanitizeMarkup(html) {
return window.sanitizeHtml ? window.sanitizeHtml(html) : String(html ?? '');
}
function cssEscapeValue(value) {
const raw = String(value ?? '');
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(raw);
}
return raw.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function findPlayFileByUrl(url) {
return document.querySelector(`.play-file[data-url="${cssEscapeValue(url)}"]`);
}
// Convert a timecode string like "00:17" or "01:02:03" into total seconds.
function parseTimecode(text) {
const parts = text.trim().split(':').map(part => parseInt(part, 10));
@ -143,7 +170,7 @@ function jumpToTimecode(seconds, relUrl) {
player.audio.addEventListener('loadedmetadata', seekWhenReady, { once: true });
const trackLink = document.querySelector(`.play-file[data-url="${relUrl}"]`);
const trackLink = findPlayFileByUrl(relUrl);
if (trackLink) {
trackLink.click();
} else {
@ -195,7 +222,7 @@ function paintFile() {
if (currentTrackPath) {
const currentMusicFile = currentMusicFiles.find(file => file.path === currentTrackPath);
if (currentMusicFile) {
const currentMusicFileElement = document.querySelector(`.play-file[data-url="${currentMusicFile.path}"]`);
const currentMusicFileElement = findPlayFileByUrl(currentMusicFile.path);
if (currentMusicFileElement) {
const fileItem = currentMusicFileElement.closest('.file-item');
fileItem.classList.add('currently-playing');
@ -205,10 +232,14 @@ function paintFile() {
}
function renderContent(data) {
const esc = safeHtml;
// Render breadcrumbs
let breadcrumbHTML = '';
data.breadcrumbs.forEach((crumb, index) => {
breadcrumbHTML += `<a href="#" class="breadcrumb-link" data-path="${crumb.path}">${crumb.name}</a>`;
const crumbPath = String(crumb.path || '');
const crumbName = String(crumb.name || '');
breadcrumbHTML += `<a href="#" class="breadcrumb-link" data-path="${esc(crumbPath)}">${esc(crumbName)}</a>`;
if (index < data.breadcrumbs.length - 1) {
breadcrumbHTML += `<span class="separator">&#8203;&gt;</span>`;
}
@ -229,39 +260,47 @@ function renderContent(data) {
// Display thumbnails grid
contentHTML += '<div class="images-grid">';
imageFiles.forEach(file => {
const thumbUrl = `${file.path}?thumbnail=true`;
const filePath = String(file.path || '');
const fileName = String(file.name || '');
const fileType = String(file.file_type || '');
const encodedPath = encodeSubpath(filePath);
const thumbUrl = `${encodedPath}?thumbnail=true`;
contentHTML += `
<div class="image-item">
<a href="#" class="play-file image-link"
data-url="${file.path}"
data-file-type="${file.file_type}">
data-url="${esc(filePath)}"
data-file-type="${esc(fileType)}">
<img
src="/media/${thumbUrl}"
data-thumb-url="/media/${thumbUrl}"
data-full-url="/media/${file.path}"
data-full-url="/media/${encodedPath}"
class="thumbnail"
onload="handleThumbnailLoad(this)"
onerror="handleThumbnailError(this)"
/>
</a>
<span class="file-name">${file.name}</span>
<span class="file-name">${esc(fileName)}</span>
</div>
`;
});
contentHTML += '</div>';
} else {
share_link = '';
let share_link = '';
// Render directories normally
if (data.directories.length > 0) {
const areAllShort = data.directories.every(dir => dir.name.length <= 10) && data.files.length === 0;
if (areAllShort && data.breadcrumbs.length !== 1) {
contentHTML += '<div class="directories-grid">';
data.directories.forEach(dir => {
const dirPath = String(dir.path || '');
const dirName = String(dir.name || '');
if (admin_enabled && data.breadcrumbs.length != 1 && dir.share) {
share_link = `<a href="#" class="create-share" data-url="${dir.path}"><i class="bi bi-gear"></i></a>`;
share_link = `<a href="#" class="create-share" data-url="${esc(dirPath)}"><i class="bi bi-gear"></i></a>`;
} else {
share_link = '';
}
contentHTML += `<div class="directory-item"><a href="#" class="directory-link" data-path="${dir.path}"><i class="bi bi-folder"></i> ${dir.name}</a>
contentHTML += `<div class="directory-item"><a href="#" class="directory-link" data-path="${esc(dirPath)}"><i class="bi bi-folder"></i> ${esc(dirName)}</a>
${share_link}
</div>`;
});
@ -278,15 +317,20 @@ function renderContent(data) {
}
data.directories.forEach(dir => {
const dirPath = String(dir.path || '');
const dirName = String(dir.name || '');
if (admin_enabled && data.breadcrumbs.length != 1 && dir.share) {
share_link = `<a href="#" class="create-share" data-url="${dir.path}"><i class="bi bi-gear"></i></a>`;
share_link = `<a href="#" class="create-share" data-url="${esc(dirPath)}"><i class="bi bi-gear"></i></a>`;
} else {
share_link = '';
}
if (dir.path.includes('toplist')) {
let link_symbol = '<i class="bi bi-folder"></i>';
if (dirPath.includes('toplist')) {
link_symbol = '<i class="bi bi-star"></i>';
} else {
link_symbol = '<i class="bi bi-folder"></i>';
}
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="${dir.path}">${link_symbol} ${dir.name}</a>${share_link}</li>`;
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="${esc(dirPath)}">${link_symbol} ${esc(dirName)}</a>${share_link}</li>`;
});
contentHTML += '</ul>';
}
@ -301,20 +345,24 @@ function renderContent(data) {
if (nonImageFiles.length > 0) {
contentHTML += '<ul>';
nonImageFiles.forEach((file, idx) => {
const filePath = String(file.path || '');
const fileType = String(file.file_type || '');
const fileName = String(file.name || '');
const displayName = fileName.replace('.mp3', '');
let symbol = '<i class="bi bi-file-earmark"></i>';
if (file.file_type === 'music') {
if (fileType === 'music') {
symbol = '<i class="bi bi-volume-up"></i>';
currentMusicFiles.push({ path: file.path, index: idx, title: file.name.replace('.mp3', '') });
} else if (file.file_type === 'image') {
currentMusicFiles.push({ path: filePath, index: idx, title: displayName });
} else if (fileType === 'image') {
symbol = '<i class="bi bi-image"></i>';
} else if ((file.name || '').toLowerCase().endsWith('.sng')) {
} else if (fileName.toLowerCase().endsWith('.sng')) {
symbol = '<i class="bi bi-music-note-list"></i>';
}
const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
const indexAttr = fileType === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
contentHTML += `<li class="file-item">
<a href="#" class="play-file"${indexAttr} data-url="${file.path}" data-file-type="${file.file_type}">${symbol} ${file.name.replace('.mp3', '')}</a>`;
<a href="#" class="play-file"${indexAttr} data-url="${esc(filePath)}" data-file-type="${esc(fileType)}">${symbol} ${esc(displayName)}</a>`;
if (file.has_transcript) {
contentHTML += `<a href="#" class="show-transcript" data-url="${file.transcript_url}" data-audio-url="${file.path}" title="Show Transcript"><i class="bi bi-journal-text"></i></a>`;
contentHTML += `<a href="#" class="show-transcript" data-url="${esc(file.transcript_url || '')}" data-audio-url="${esc(filePath)}" title="Show Transcript"><i class="bi bi-journal-text"></i></a>`;
}
contentHTML += `</li>`;
});
@ -325,23 +373,27 @@ function renderContent(data) {
if (imageFiles.length > 0) {
contentHTML += '<div class="images-grid">';
imageFiles.forEach(file => {
const thumbUrl = `${file.path}?thumbnail=true`;
const filePath = String(file.path || '');
const fileName = String(file.name || '');
const fileType = String(file.file_type || '');
const encodedPath = encodeSubpath(filePath);
const thumbUrl = `${encodedPath}?thumbnail=true`;
contentHTML += `
<div class="image-item">
<a href="#" class="play-file image-link"
data-url="${file.path}"
data-file-type="${file.file_type}">
data-url="${esc(filePath)}"
data-file-type="${esc(fileType)}">
<img
src="/media/${thumbUrl}"
data-thumb-url="/media/${thumbUrl}"
data-full-url="/media/${file.path}"
data-full-url="/media/${encodedPath}"
class="thumbnail"
onload="handleThumbnailLoad(this)"
onerror="handleThumbnailError(this)"
/>
</a>
<span class="file-name">${file.name}</span>
<span class="file-name">${esc(fileName)}</span>
</div>
`;
});
@ -414,11 +466,11 @@ function loadDirectory(subpath) {
if (data.breadcrumbs) {
renderContent(data);
} else if (data.error) {
document.getElementById('content').innerHTML = `<div class="alert alert-warning">${data.error}</div>`;
document.getElementById('content').innerHTML = `<div class="alert alert-warning">${safeHtml(data.error)}</div>`;
return;
}
if (data.playfile) {
const playFileLink = document.querySelector(`.play-file[data-url="${data.playfile}"]`);
const playFileLink = findPlayFileByUrl(data.playfile);
if (playFileLink) {
playFileLink.click();
}
@ -430,7 +482,7 @@ function loadDirectory(subpath) {
clearTimeout(spinnerTimer);
hideSpinner();
console.error('Error loading directory:', error);
document.getElementById('content').innerHTML = `<p>Error loading directory!</p><p>${error}</p>`;
document.getElementById('content').innerHTML = `<p>Error loading directory!</p><p>${safeHtml(String(error))}</p>`;
throw error; // Propagate the error
});
}
@ -440,7 +492,7 @@ function preload_audio() {
// Prefetch the next file by triggering the backend diskcache.
if (currentMusicIndex >= 0 && currentMusicIndex < currentMusicFiles.length - 1) {
const nextFile = currentMusicFiles[currentMusicIndex + 1];
const nextMediaUrl = '/media/' + nextFile.path;
const nextMediaUrl = '/media/' + encodeSubpath(nextFile.path);
// Use a HEAD request so that the backend reads and caches the file without returning the full content.
fetch(nextMediaUrl, {
method: 'HEAD',
@ -462,7 +514,7 @@ function attachEventListeners() {
event.preventDefault();
const newPath = this.getAttribute('data-path');
loadDirectory(newPath);
history.pushState({ subpath: newPath }, '', newPath ? '/path/' + newPath : '/');
history.pushState({ subpath: newPath }, '', newPath ? '/path/' + encodeSubpath(newPath) : '/');
// Scroll to the top of the page after click:
window.scrollTo({ top: 0, behavior: 'smooth' });
});
@ -474,7 +526,7 @@ function attachEventListeners() {
event.preventDefault();
const newPath = this.getAttribute('data-path');
loadDirectory(newPath);
history.pushState({ subpath: newPath }, '', newPath ? '/path/' + newPath : '/');
history.pushState({ subpath: newPath }, '', newPath ? '/path/' + encodeSubpath(newPath) : '/');
});
});
@ -530,7 +582,8 @@ document.querySelectorAll('.play-file').forEach(link => {
} else {
// serve like a download
const reqId = crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2));
const urlWithReq = `/media/${relUrl}${relUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(reqId)}`;
const encodedRelUrl = encodeSubpath(relUrl);
const urlWithReq = `/media/${encodedRelUrl}${encodedRelUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(reqId)}`;
window.location.href = urlWithReq;
}
});
@ -570,7 +623,8 @@ document.querySelectorAll('.play-file').forEach(link => {
data = data.replace(regex, '<span class="highlight">$1</span>');
}
}
document.getElementById('transcriptContent').innerHTML = marked.parse(data);
const transcriptHtml = marked.parse(data);
document.getElementById('transcriptContent').innerHTML = sanitizeMarkup(transcriptHtml);
attachTranscriptTimecodes(audioUrl);
document.getElementById('transcriptModal').style.display = 'block';
})
@ -586,23 +640,9 @@ document.querySelectorAll('.play-file').forEach(link => {
document.querySelectorAll('.create-share').forEach(link => {
link.addEventListener('click', function (event) {
event.preventDefault();
const url = '/create_token/' + this.getAttribute('data-url');
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.text();
})
.then(data => {
document.getElementById('transcriptContent').innerHTML = data;
document.getElementById('transcriptModal').style.display = 'block';
})
.catch(error => {
console.error('Error creating share:', error);
document.getElementById('transcriptContent').innerHTML = "<p>You can't share this.</p>";
document.getElementById('transcriptModal').style.display = 'block';
});
const rel = this.getAttribute('data-url') || '';
const url = '/create_token/' + encodeSubpath(rel);
window.open(url, '_blank', 'noopener');
});
});
@ -618,13 +658,7 @@ document.querySelectorAll('.play-file').forEach(link => {
} // End of attachEventListeners function
function escapeHtml(text) {
return (text || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
return safeHtml(text);
}
function formatSngLine(line) {
@ -925,7 +959,7 @@ if (!window.__appAudioEndedBound) {
// Now, if there's a next track, auto-play it.
if (currentMusicIndex >= 0 && currentMusicIndex < currentMusicFiles.length - 1) {
const nextFile = currentMusicFiles[currentMusicIndex + 1];
const nextLink = document.querySelector(`.play-file[data-url=\"${nextFile.path}\"]`);
const nextLink = findPlayFileByUrl(nextFile.path);
if (nextLink) {
nextLink.click();
}
@ -981,7 +1015,7 @@ function clearPendingFolderOpen() {
function highlightPendingFile(filePath) {
if (!filePath) return;
const target = document.querySelector(`.play-file[data-url=\"${filePath}\"]`);
const target = findPlayFileByUrl(filePath);
if (target) {
target.classList.add('search-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });

View File

@ -309,10 +309,12 @@
row.dataset.location = entry.location || '';
row.dataset.details = entry.details || '';
row.className = 'calendar-entry-row';
const safeTitle = escapeHtml(entry.title || '');
const safeLocation = escapeHtml(entry.location || '');
row.innerHTML = `
<td>${timeValue || '-'}</td>
<td>${entry.title || ''} ${entry.details ? '<span class="details-indicator" title="Details vorhanden">ⓘ</span>' : ''}</td>
<td>${entry.location || ''}</td>
<td>${safeTitle} ${entry.details ? '<span class="details-indicator" title="Details vorhanden">ⓘ</span>' : ''}</td>
<td>${safeLocation}</td>
`;
const rows = Array.from(tbody.querySelectorAll('tr.calendar-entry-row'));

View File

@ -1,4 +1,14 @@
(() => {
const esc = (value) => {
if (window.escapeHtml) return window.escapeHtml(value);
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
};
let map = null;
let socket = null;
let activeDots = null;
@ -43,9 +53,9 @@
function updatePopup(dot) {
const location = dot.connections[0];
const content = `
<strong>${location.city}, ${location.country}</strong><br>
<strong>${esc(location.city)}, ${esc(location.country)}</strong><br>
Connections: ${dot.mass}<br>
Latest: ${new Date(location.timestamp).toLocaleString()}
Latest: ${esc(new Date(location.timestamp).toLocaleString())}
`;
dot.marker.bindPopup(content);
}
@ -216,13 +226,13 @@
tr.classList.remove('slide-in'), { once: true });
}
tr.innerHTML = `
<td>${record.timestamp}</td>
<td>${record.location}</td>
<td>${record.user_agent}</td>
<td>${record.full_path}</td>
<td title="${record.filesize}">${record.filesize_human || record.filesize}</td>
<td>${record.mime_typ}</td>
<td>${record.cached}</td>
<td>${esc(record.timestamp)}</td>
<td>${esc(record.location)}</td>
<td>${esc(record.user_agent)}</td>
<td>${esc(record.full_path)}</td>
<td title="${esc(record.filesize)}">${esc(record.filesize_human || record.filesize)}</td>
<td>${esc(record.mime_typ)}</td>
<td>${esc(record.cached)}</td>
`;
return tr;
}

View File

@ -77,7 +77,19 @@
const headerTitle = document.createElement('div');
headerTitle.className = 'd-flex justify-content-between align-items-center';
const folderNames = rec.folders.map(f => f.foldername).join(', ');
headerTitle.innerHTML = `<strong>Ordner: ${folderNames}${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}</strong><i class="bi bi-chevron-down"></i>`;
const titleStrong = document.createElement('strong');
titleStrong.textContent = `Ordner: ${folderNames}`;
if (expired) {
const expiredMarker = document.createElement('span');
expiredMarker.className = 'text-danger fw-bold';
expiredMarker.textContent = ' ! abgelaufen !';
titleStrong.appendChild(document.createTextNode(' '));
titleStrong.appendChild(expiredMarker);
}
const chevron = document.createElement('i');
chevron.className = 'bi bi-chevron-down';
headerTitle.appendChild(titleStrong);
headerTitle.appendChild(chevron);
cardHeader.appendChild(headerTitle);
wrapper.appendChild(cardHeader);
@ -231,7 +243,7 @@
if (!isEdit) {
const openButton = document.createElement('button');
openButton.className = 'btn btn-secondary btn-sm me-2';
openButton.onclick = () => window.open(`/?secret=${rec.secret}`, '_self');
openButton.onclick = () => window.open(`/?secret=${encodeURIComponent(rec.secret)}`, '_self');
openButton.textContent = 'Link öffnen';
actions.appendChild(openButton);
}
@ -239,7 +251,7 @@
if (!isEdit) {
const openButton = document.createElement('button');
openButton.className = 'btn btn-secondary btn-sm me-2';
openButton.onclick = () => toClipboard(`${window.location.origin}/?secret=${rec.secret}`);
openButton.onclick = () => toClipboard(`${window.location.origin}/?secret=${encodeURIComponent(rec.secret)}`);
openButton.textContent = 'Link kopieren';
actions.appendChild(openButton);
}

View File

@ -67,3 +67,109 @@ function printToken() {
popup.close();
};
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function sanitizeHtml(dirtyHtml) {
const template = document.createElement('template');
template.innerHTML = String(dirtyHtml ?? '');
// Remove high-risk elements entirely.
template.content.querySelectorAll('script, iframe, object, embed, link, meta, style, base, form').forEach((el) => {
el.remove();
});
const hasUnsafeScheme = (raw) => /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(raw);
const isAllowedScheme = (raw) => /^(https?|mailto|tel):/i.test(raw);
const isAllowedDataImage = (raw) => /^data:image\/(png|gif|jpe?g|webp);base64,/i.test(raw);
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT);
let node = walker.nextNode();
while (node) {
[...node.attributes].forEach((attr) => {
const name = attr.name.toLowerCase();
const value = String(attr.value || '').trim();
if (name.startsWith('on') || name === 'srcdoc') {
node.removeAttribute(attr.name);
return;
}
if (name === 'href' || name === 'src' || name === 'xlink:href' || name === 'formaction') {
if (!value) return;
if (hasUnsafeScheme(value) && !isAllowedScheme(value) && !isAllowedDataImage(value)) {
node.removeAttribute(attr.name);
return;
}
if (name === 'href' && /^https?:/i.test(value)) {
node.setAttribute('rel', 'noopener noreferrer');
}
}
});
node = walker.nextNode();
}
return template.innerHTML;
}
window.escapeHtml = escapeHtml;
window.sanitizeHtml = sanitizeHtml;
// Attach CSRF token automatically to same-origin mutating fetch requests.
(() => {
if (window.__csrfFetchPatched) return;
window.__csrfFetchPatched = true;
const nativeFetch = window.fetch.bind(window);
const protectedMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
function getMethod(input, init) {
if (init && init.method) return String(init.method).toUpperCase();
if (input instanceof Request && input.method) return String(input.method).toUpperCase();
return 'GET';
}
function getUrl(input) {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.toString();
if (input instanceof Request) return input.url;
return '';
}
window.fetch = function patchedFetch(input, init = {}) {
const method = getMethod(input, init);
const token = window.CSRF_TOKEN;
if (!token || !protectedMethods.has(method)) {
return nativeFetch(input, init);
}
let targetUrl;
try {
targetUrl = new URL(getUrl(input), window.location.href);
} catch (err) {
return nativeFetch(input, init);
}
if (targetUrl.origin !== window.location.origin) {
return nativeFetch(input, init);
}
const headers = new Headers(
(init && init.headers) || (input instanceof Request ? input.headers : undefined)
);
if (!headers.has('X-CSRF-Token')) {
headers.set('X-CSRF-Token', token);
}
const nextInit = { ...init, method, headers };
return nativeFetch(input, nextInit);
};
})();

View File

@ -22,6 +22,7 @@
position: fixed;
z-index: 3000;
inset: 0;
--gallery-edge-gap: 10px;
background-color: rgba(0, 0, 0, 0.82);
background-image:
radial-gradient(circle at 50% 15%, rgba(42, 42, 42, 0.52), rgba(8, 8, 8, 0.95) 56%, #000 100%);
@ -33,7 +34,7 @@
#gallery-stage {
position: absolute;
inset: 82px 22px 96px;
inset: var(--gallery-edge-gap);
cursor: pointer;
}
@ -42,8 +43,8 @@
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: min(96vw, calc(100vw - 80px));
max-height: calc(100vh - 190px);
max-width: calc(100vw - (var(--gallery-edge-gap) * 2));
max-height: calc(100vh - (var(--gallery-edge-gap) * 2));
object-fit: contain;
margin: auto;
display: block;
@ -258,7 +259,7 @@
#gallery-filename,
#gallery-position {
color: #fff;
background: rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, 0.42);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
padding: 0.45rem 0.72rem;
@ -285,10 +286,10 @@
background:
linear-gradient(
90deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(255, 255, 255, 0.95) var(--filename-progress),
rgba(0, 0, 0, 0.62) var(--filename-progress),
rgba(0, 0, 0, 0.62) 100%
rgba(255, 255, 255, 0.56) 0%,
rgba(255, 255, 255, 0.56) var(--filename-progress),
rgba(0, 0, 0, 0.44) var(--filename-progress),
rgba(0, 0, 0, 0.44) 100%
);
z-index: 0;
}
@ -310,7 +311,7 @@
left: 0;
width: 100%;
height: 100%;
padding: 0.45rem 0.72rem;
padding: inherit;
box-sizing: border-box;
z-index: 2;
overflow: hidden;
@ -365,13 +366,17 @@
}
@media (max-width: 860px) {
#gallery-modal {
--gallery-edge-gap: 8px;
}
#gallery-stage {
inset: 74px 10px 110px;
inset: var(--gallery-edge-gap);
}
#gallery-modal-content {
max-width: calc(100vw - 20px);
max-height: calc(100vh - 210px);
max-width: calc(100vw - (var(--gallery-edge-gap) * 2));
max-height: calc(100vh - (var(--gallery-edge-gap) * 2));
border-radius: 10px;
}
@ -453,13 +458,17 @@
}
@media (max-height: 520px) and (orientation: landscape) {
#gallery-modal {
--gallery-edge-gap: 6px;
}
#gallery-stage {
inset: 44px 10px 60px;
inset: var(--gallery-edge-gap);
}
#gallery-modal-content {
max-width: calc(100vw - 20px);
max-height: calc(100vh - 112px);
max-width: calc(100vw - (var(--gallery-edge-gap) * 2));
max-height: calc(100vh - (var(--gallery-edge-gap) * 2));
border-radius: 8px;
}

View File

@ -29,6 +29,7 @@ if (typeof currentGalleryIndex === "undefined") {
}
let currentExifRequestId = 0;
const previewRefreshInFlight = new Set();
function encodeGalleryPath(relUrl) {
if (!relUrl) {
@ -40,6 +41,73 @@ function encodeGalleryPath(relUrl) {
.join('/');
}
function findBrowserThumbnailByRelUrl(relUrl) {
if (!relUrl) {
return null;
}
const links = document.querySelectorAll('.image-link[data-url]');
for (const link of links) {
if ((link.dataset.url || '') === relUrl) {
return link.querySelector('img.thumbnail');
}
}
return null;
}
function isFallbackThumbnail(imgEl) {
if (!imgEl) {
return false;
}
const src = imgEl.getAttribute('src') || '';
return imgEl.classList.contains('thumbnail-fallback') || src.startsWith('data:image/');
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function refreshBrowserThumbnailIfMissing(relUrl) {
if (!relUrl || previewRefreshInFlight.has(relUrl)) {
return;
}
const thumbImg = findBrowserThumbnailByRelUrl(relUrl);
if (!thumbImg || !isFallbackThumbnail(thumbImg)) {
return;
}
const baseThumbUrl = thumbImg.dataset.thumbUrl;
if (!baseThumbUrl) {
return;
}
previewRefreshInFlight.add(relUrl);
const separator = baseThumbUrl.includes('?') ? '&' : '?';
try {
for (let attempt = 0; attempt < 2; attempt++) {
const refreshUrl = `${baseThumbUrl}${separator}_=${Date.now()}-${attempt}`;
const response = await fetch(refreshUrl, { method: 'GET', cache: 'no-store' }).catch(() => null);
if (response && response.ok && response.status !== 204) {
thumbImg.classList.remove('thumbnail-fallback');
if (typeof handleThumbnailLoad === 'function') {
thumbImg.onload = () => handleThumbnailLoad(thumbImg);
}
if (typeof handleThumbnailError === 'function') {
thumbImg.onerror = () => handleThumbnailError(thumbImg);
}
thumbImg.src = refreshUrl;
return;
}
if (attempt === 0) {
await delay(250);
}
}
} finally {
previewRefreshInFlight.delete(relUrl);
}
}
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
@ -276,6 +344,11 @@ function showGalleryNav() {
}, 1000);
}
function hideGalleryNav() {
clearTimeout(navHideTimer);
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.add('nav-hidden'));
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
@ -375,6 +448,7 @@ function showGalleryImage(relUrl) {
clearTimeout(loaderTimeout);
loader.style.display = 'none';
updateFilenameDisplay(relUrl);
refreshBrowserThumbnailIfMissing(relUrl);
// Select all current images in the gallery stage
const existingImages = stage.querySelectorAll('img');
@ -449,8 +523,12 @@ function showGalleryImage(relUrl) {
function preloadNextImage() {
if (currentGalleryIndex < currentGalleryImages.length - 1) {
const nextRelUrl = currentGalleryImages[currentGalleryIndex + 1];
const nextImage = new Image();
nextImage.src = '/media/' + currentGalleryImages[currentGalleryIndex + 1];
nextImage.onload = function () {
refreshBrowserThumbnailIfMissing(nextRelUrl);
};
nextImage.src = '/media/' + nextRelUrl;
}
}
@ -703,7 +781,6 @@ function handlePinchStart(e) {
pinchStartMidY = (e.touches[0].pageY + e.touches[1].pageY) / 2;
pinchStartTranslateX = currentTranslateX;
pinchStartTranslateY = currentTranslateY;
showGalleryNav();
return;
}
@ -736,7 +813,11 @@ function handlePinchMove(e) {
currentTranslateX = pinchStartTranslateX + (midX - pinchStartMidX);
currentTranslateY = pinchStartTranslateY + (midY - pinchStartMidY);
applyImageTransform(e.currentTarget);
if (currentScale > 1.01) {
showGalleryNav();
} else {
hideGalleryNav();
}
return;
}
@ -776,6 +857,9 @@ function handlePinchEnd(e) {
currentScale = 1;
currentTranslateX = 0;
currentTranslateY = 0;
applyImageTransform(image);
hideGalleryNav();
return;
}
applyImageTransform(image);
showGalleryNav();

View File

@ -181,6 +181,8 @@ async function loadMessages() {
function renderMessages() {
const container = document.getElementById('messages-container');
const esc = (value) => (window.escapeHtml ? window.escapeHtml(value) : escapeHtml(value));
const sanitize = (html) => (window.sanitizeHtml ? window.sanitizeHtml(html) : String(html ?? ''));
if (messagesData.length === 0) {
container.innerHTML = '<div class="no-messages">Keine Nachrichten vorhanden</div>';
@ -189,12 +191,14 @@ function renderMessages() {
let html = '';
messagesData.forEach(message => {
const messageId = Number(message.id);
const safeMessageId = Number.isFinite(messageId) ? messageId : 0;
const datetime = formatDateTime(message.datetime);
// Convert folder: protocol to clickable buttons before markdown parsing
let processedContent = message.content || '';
processedContent = processedContent.replace(/\[([^\]]+)\]\(folder:([^)]+)\)/g,
(match, name, path) => `<button class="folder-link-btn" data-folder-path="${escapeHtml(path)}"><i class="bi bi-folder2"></i> ${escapeHtml(name)}</button>`
(match, name, path) => `<button class="folder-link-btn" data-folder-path="${esc(path)}"><i class="bi bi-folder2"></i> ${esc(name)}</button>`
);
// Configure marked to allow all links
@ -202,23 +206,23 @@ function renderMessages() {
breaks: true,
gfm: true
});
const contentHtml = marked.parse(processedContent);
const contentHtml = sanitize(marked.parse(processedContent));
html += `
<div class="message-card" data-message-id="${message.id}">
<div class="message-card" data-message-id="${safeMessageId}">
<div class="message-header">
<h2 class="message-title">${escapeHtml(message.title)}</h2>
<div class="message-datetime">${datetime}</div>
<h2 class="message-title">${esc(message.title)}</h2>
<div class="message-datetime">${esc(datetime)}</div>
</div>
<div class="message-content">
${contentHtml}
</div>
${admin_enabled ? `
<div class="message-actions">
<button class="btn-edit" onclick="editMessage(${message.id})" title="Bearbeiten">
<button class="btn-edit" onclick="editMessage(${safeMessageId})" title="Bearbeiten">
<i class="bi bi-pencil"></i>
</button>
<button class="btn-delete" onclick="deleteMessage(${message.id})" title="Löschen">
<button class="btn-delete" onclick="deleteMessage(${safeMessageId})" title="Löschen">
<i class="bi bi-trash"></i>
</button>
</div>
@ -247,6 +251,7 @@ function formatDateTime(datetimeStr) {
}
function escapeHtml(text) {
if (window.escapeHtml) return window.escapeHtml(text);
const map = {
'&': '&amp;',
'<': '&lt;',
@ -254,7 +259,7 @@ function escapeHtml(text) {
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
return String(text ?? '').replace(/[&<>"']/g, m => map[m]);
}
function openMessageModal(messageId = null) {

View File

@ -2,6 +2,21 @@ function initSearch() {
const form = document.getElementById('searchForm');
if (!form || form.dataset.bound) return;
form.dataset.bound = '1';
const esc = (value) => {
if (window.escapeHtml) return window.escapeHtml(value);
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
};
const encodePath = (path) => {
const raw = String(path || '');
if (typeof encodeSubpath === 'function') return encodeSubpath(raw);
return encodeURI(raw);
};
// Function to render search results from a response object
function renderResults(data) {
@ -17,36 +32,46 @@ function initSearch() {
if (results.length > 0) {
results.forEach(file => {
const card = document.createElement('div');
const filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.');
const parentFolder = file.relative_path.split('/').slice(0, -1).join('/');
const relativePath = String(file.relative_path || '');
const filename = String(file.filename || '');
const filenameWithoutExtension = filename.split('.').slice(0, -1).join('.');
const parentFolder = relativePath.split('/').slice(0, -1).join('/');
const transcriptURL = '/transcript/' + parentFolder + '/Transkription/' + filenameWithoutExtension + '.md';
const isAudio = audioExts.includes((file.filetype || '').toLowerCase());
const isSng = (file.filetype || '').toLowerCase() === '.sng' || (file.filename || '').toLowerCase().endsWith('.sng');
const encodedRelPath = encodeURI(file.relative_path);
const isSng = (file.filetype || '').toLowerCase() === '.sng' || filename.toLowerCase().endsWith('.sng');
const encodedRelPath = encodePath(relativePath);
const fulltextType = file.fulltext_type || 'transcript';
const fulltextUrl = file.fulltext_url || transcriptURL;
const fulltextLabel = 'Treffer im Volltext';
const fulltextAction = fulltextType === 'sng'
? `<a href="#" class="open-sng-link" data-path="${file.relative_path}" title="Liedtext öffnen"><i class="bi bi-journal-text"></i></a>`
: `<a href="#" class="show-transcript" data-url="${fulltextUrl}" data-audio-url="${file.relative_path}" highlight="${file.query}"><i class="bi bi-journal-text"></i></a>`;
? `<a href="#" class="open-sng-link" data-path="${esc(relativePath)}" title="Liedtext öffnen"><i class="bi bi-journal-text"></i></a>`
: `<a href="#" class="show-transcript" data-url="${esc(fulltextUrl)}" data-audio-url="${esc(relativePath)}" highlight="${esc(file.query || '')}"><i class="bi bi-journal-text"></i></a>`;
card.className = 'card';
let fileAction = '';
if (isSng) {
fileAction = `<button class="btn btn-light open-sng-btn" data-path="${file.relative_path}" style="width:100%;"><i class="bi bi-music-note-list"></i> ${filenameWithoutExtension}</button>`;
fileAction = `<button class="btn btn-light open-sng-btn" data-path="${esc(relativePath)}" style="width:100%;"><i class="bi bi-music-note-list"></i> ${esc(filenameWithoutExtension)}</button>`;
} else if (isAudio) {
fileAction = `<button class="btn btn-light play-audio-btn" onclick="player.loadTrack('${file.relative_path}')" style="width:100%;"><i class="bi bi-volume-up"></i> ${filenameWithoutExtension}</button>`;
fileAction = `<button class="btn btn-light play-audio-btn" data-path="${esc(relativePath)}" style="width:100%;"><i class="bi bi-volume-up"></i> ${esc(filenameWithoutExtension)}</button>`;
} else {
fileAction = `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${file.filename}</a>`;
fileAction = `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${esc(filename)}</a>`;
}
const hitcount = Number(file.hitcount);
const safeHitcount = Number.isFinite(hitcount) ? hitcount : 0;
const transcriptHits = Number(file.transcript_hits);
const safeTranscriptHits = Number.isFinite(transcriptHits) ? transcriptHits : 0;
const perfDate = (file.performance_date !== undefined && file.performance_date !== null)
? String(file.performance_date).trim()
: '';
card.innerHTML = `
<div class="card-body">
<p>${fileAction}</p>
<p><button class="btn btn-light btn-sm folder-open-btn" data-folder="${parentFolder}" data-file="${file.relative_path}" style="width:100%;"><i class="bi bi-folder"></i> ${parentFolder || 'Ordner'}</button></p>
<p class="card-text">Anzahl Downloads: ${file.hitcount}</p>
${ (file.performance_date !== undefined && file.performance_date !== null && String(file.performance_date).trim() !== '') ? `<p class="card-text">Datum: ${file.performance_date}</p>` : ``}
${ file.transcript_hits !== undefined ? `<p class="card-text">${fulltextLabel}: ${file.transcript_hits} ${fulltextAction}</p>` : ``}
<p><button class="btn btn-light btn-sm folder-open-btn" data-folder="${esc(parentFolder)}" data-file="${esc(relativePath)}" style="width:100%;"><i class="bi bi-folder"></i> ${esc(parentFolder || 'Ordner')}</button></p>
<p class="card-text">Anzahl Downloads: ${safeHitcount}</p>
${ perfDate !== '' ? `<p class="card-text">Datum: ${esc(perfDate)}</p>` : ``}
${ file.transcript_hits !== undefined ? `<p class="card-text">${fulltextLabel}: ${safeTranscriptHits} ${fulltextAction}</p>` : ``}
</div>
`;
resultsDiv.appendChild(card);
@ -59,6 +84,7 @@ function initSearch() {
}
attachEventListeners();
attachSearchFolderButtons();
attachSearchAudioButtons();
attachSearchSngButtons();
attachSearchFulltextButtons();
} else {
@ -171,7 +197,19 @@ function initSearch() {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(async response => {
let data = {};
try {
data = await response.json();
} catch (err) {
data = {};
}
if (!response.ok) {
const message = (data && data.error) ? data.error : `Search failed (${response.status})`;
throw new Error(message);
}
return data;
})
.then(data => {
// Render the results
renderResults(data);
@ -189,6 +227,10 @@ function initSearch() {
})
.catch(error => {
console.error('Error:', error);
const resultsDiv = document.getElementById('results');
if (resultsDiv) {
resultsDiv.innerHTML = `<div class="alert alert-danger">${esc(error.message || 'Search failed')}</div>`;
}
});
// Always clear/hide spinner once the request settles
if (typeof fetchPromise.finally === 'function') {
@ -243,6 +285,18 @@ function attachSearchFolderButtons() {
});
}
function attachSearchAudioButtons() {
document.querySelectorAll('.play-audio-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const path = btn.dataset.path;
if (typeof player !== 'undefined' && player && typeof player.loadTrack === 'function') {
player.loadTrack(path);
}
});
});
}
function attachSearchSngButtons() {
document.querySelectorAll('.open-sng-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
@ -272,7 +326,10 @@ function openFolderAndHighlight(folderPath, filePath) {
// Switch back to main view before loading folder
viewMain();
loadDirectory(targetFolder).then(() => {
const target = document.querySelector(`.play-file[data-url=\"${filePath}\"]`);
const cssValue = (window.CSS && typeof window.CSS.escape === 'function')
? window.CSS.escape(String(filePath || ''))
: String(filePath || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const target = document.querySelector(`.play-file[data-url="${cssValue}"]`);
if (target) {
target.classList.add('search-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });

View File

@ -61,6 +61,7 @@
<button type="button" id="backBtn" class="btn-close search-close-btn" aria-label="beenden"></button>
<div class="container">
<form id="searchForm" method="post" class="mb-4">
<input type="hidden" name="_csrf_token" value="{{ csrf_token }}">
<!-- Suchwörter -->
<div class="mb-3 search-query-wrap">
<label for="query" class="h5 form-label">Suchwörter:</label>

View File

@ -20,7 +20,10 @@
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='audioplayer.css') }}">
<script src="{{ url_for('static', filename='functions.js') }}"></script>
<script>const admin_enabled = {{ admin_enabled | default(false) | tojson | safe }};</script>
<script>
const admin_enabled = {{ admin_enabled | default(false) | tojson | safe }};
window.CSRF_TOKEN = {{ csrf_token | tojson | safe }};
</script>
{% block head_extra %}{% endblock %}
</head>

View File

@ -28,6 +28,7 @@
<button class="btn btn-secondary btn-sm" onclick="toClipboard('{{ secret_url[secret] }}')">Link kopieren</button>
<br>
<form method="post" action="{{ url_for('remove_secret') }}" class="mt-3">
<input type="hidden" name="_csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="secret" value="{{ secret }}">
<button type="submit" class="btn btn-danger btn-sm">Link entfernen</button>
</form>
@ -63,6 +64,7 @@
<button class="btn btn-secondary btn-sm" onclick="toClipboard('{{ token_url[token] }}')">Link kopieren</button>
<br>
<form method="post" action="{{ url_for('remove_token') }}" class="mt-3">
<input type="hidden" name="_csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="token" value="{{ token }}">
<button type="submit" class="btn btn-danger btn-sm">Link entfernen</button>
</form>