Compare commits

..

No commits in common. "f6010b065d41510a85082e8eede46308132a3052" and "3c7cb11f02f2e146de151926c5f3f154165be567" have entirely different histories.

16 changed files with 140 additions and 632 deletions

127
app.py
View File

@ -1,7 +1,5 @@
import os import os
import sqlite3 import sqlite3
import secrets
import time
# Use eventlet only in production; keep dev on threading to avoid monkey_patch issues with reloader # Use eventlet only in production; keep dev on threading to avoid monkey_patch issues with reloader
FLASK_ENV = os.environ.get('FLASK_ENV', 'production') FLASK_ENV = os.environ.get('FLASK_ENV', 'production')
@ -17,7 +15,7 @@ import mimetypes
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
import diskcache import diskcache
import threading import threading
from flask_socketio import SocketIO, emit, disconnect from flask_socketio import SocketIO, emit
import geoip2.database import geoip2.database
from functools import lru_cache from functools import lru_cache
from urllib.parse import urlparse, unquote from urllib.parse import urlparse, unquote
@ -37,7 +35,7 @@ import helperfunctions as hf
import search_db_analyzer as sdb import search_db_analyzer as sdb
import fnmatch import fnmatch
import openpyxl import openpyxl
from collections import OrderedDict, defaultdict, deque from collections import OrderedDict
app_config = auth.return_app_config() app_config = auth.return_app_config()
BASE_DIR = os.path.realpath(app_config['BASE_DIR']) BASE_DIR = os.path.realpath(app_config['BASE_DIR'])
@ -52,76 +50,6 @@ _logged_request_ids = OrderedDict()
_logged_request_ids_lock = threading.Lock() _logged_request_ids_lock = threading.Lock()
_LOGGED_REQUEST_IDS_MAX = 2048 _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: def _is_duplicate_request(req_id: str) -> bool:
if not req_id: if not req_id:
@ -148,29 +76,6 @@ if FLASK_ENV == 'production':
app.config['SESSION_COOKIE_SAMESITE'] = 'None' app.config['SESSION_COOKIE_SAMESITE'] = 'None'
app.config['SESSION_COOKIE_SECURE'] = True 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('/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('/file_access', view_func=auth.require_admin(a.file_access))
app.add_url_rule('/connections', view_func=auth.require_admin(a.connections)) app.add_url_rule('/connections', view_func=auth.require_admin(a.connections))
@ -1249,21 +1154,11 @@ def media_exif(subpath):
@app.route("/transcript/<path:subpath>") @app.route("/transcript/<path:subpath>")
@auth.require_secret @auth.require_secret
def get_transcript(subpath): def get_transcript(subpath):
root, *relative_parts = subpath.split('/') root, *relative_parts = subpath.split('/')
base_path = session.get('folders', {}).get(root) base_path = session['folders'][root]
if not base_path:
return "Transcription not found", 404
full_path = os.path.join(base_path, *relative_parts) 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): if not os.path.isfile(full_path):
return "Transcription not found", 404 return "Transcription not found", 404
@ -1277,7 +1172,7 @@ def get_transcript(subpath):
def create_token(subpath): def create_token(subpath):
scheme = request.scheme # current scheme (http or https) scheme = request.scheme # current scheme (http or https)
host = request.host host = request.host
if not session.get('admin', False): if 'admin' not in session and not session.get('admin'):
return "Unauthorized", 403 return "Unauthorized", 403
folder_config = auth.return_folder_config() folder_config = auth.return_folder_config()
@ -1394,9 +1289,6 @@ def query_recent_connections():
@socketio.on('connect') @socketio.on('connect')
def handle_connect(auth=None): def handle_connect(auth=None):
global clients_connected, background_thread_running global clients_connected, background_thread_running
if not session.get('admin', False):
return False
clients_connected += 1 clients_connected += 1
print("Client connected. Total clients:", clients_connected) print("Client connected. Total clients:", clients_connected)
with thread_lock: with thread_lock:
@ -1407,16 +1299,11 @@ def handle_connect(auth=None):
@socketio.on('disconnect') @socketio.on('disconnect')
def handle_disconnect(): def handle_disconnect():
global clients_connected global clients_connected
if clients_connected > 0:
clients_connected -= 1 clients_connected -= 1
print("Client disconnected. Total clients:", clients_connected) print("Client disconnected. Total clients:", clients_connected)
@socketio.on('request_initial_data') @socketio.on('request_initial_data')
def handle_request_initial_data(): def handle_request_initial_data():
if not session.get('admin', False):
disconnect()
return
rows = a.return_file_access() rows = a.return_file_access()
connections = [ connections = [
{ {
@ -1436,10 +1323,6 @@ def handle_request_initial_data():
@socketio.on('request_map_data') @socketio.on('request_map_data')
def handle_request_map_data(): def handle_request_map_data():
"""Send initial map data from in-memory log to avoid hitting the DB.""" """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() rows = a.return_file_access()
connections = [] connections = []
for row in rows: for row in rows:

View File

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

View File

@ -23,44 +23,12 @@ FILETYPE_GROUPS = {
ALL_GROUP_EXTS = tuple(sorted({ext for group in FILETYPE_GROUPS.values() for ext in group})) ALL_GROUP_EXTS = tuple(sorted({ext for group in FILETYPE_GROUPS.values() for ext in group}))
ALL_GROUP_KEYS = set(FILETYPE_GROUPS.keys()) 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(): def searchcommand():
query = request.form.get("query", "").strip() query = request.form.get("query", "").strip()
category = request.form.get("category", "").strip() category = request.form.get("category", "").strip()
searchfolder = request.form.get("folder", "").strip() searchfolder = request.form.get("folder", "").strip()
datefrom = request.form.get("datefrom", "").strip() datefrom = request.form.get("datefrom", "").strip()
dateto = request.form.get("dateto", "").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()] filetypes = [ft.strip().lower() for ft in request.form.getlist("filetype") if ft.strip()]
if not filetypes: if not filetypes:
# Default to audio when nothing selected # Default to audio when nothing selected
@ -68,9 +36,6 @@ def searchcommand():
include_transcript = request.form.get("includeTranscript") in ["true", "on"] include_transcript = request.form.get("includeTranscript") in ["true", "on"]
words = [w for w in query.split() if w] 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() cursor = search_db.cursor()
# Determine allowed basefolders # Determine allowed basefolders
@ -89,16 +54,15 @@ def searchcommand():
fields = ['relative_path', 'filename'] fields = ['relative_path', 'filename']
for word in words: for word in words:
escaped_word = _escape_like(word) field_clauses = [f"{f} LIKE ?" for f in fields]
field_clauses = [f"{f} LIKE ? ESCAPE '\\'" for f in fields]
conditions.append(f"({ ' OR '.join(field_clauses) })") conditions.append(f"({ ' OR '.join(field_clauses) })")
for _ in fields: for _ in fields:
params.append(f"%{escaped_word}%") params.append(f"%{word}%")
# Category filter # Category filter
if category: if category:
conditions.append("filename LIKE ? ESCAPE '\\'") conditions.append("filename LIKE ?")
params.append(f"%{_escape_like(category)}%") params.append(f"%{category}%")
# Basefolder filter # Basefolder filter
if allowed_basefolders: if allowed_basefolders:
@ -143,17 +107,12 @@ def searchcommand():
conditions.append("(" + " OR ".join(clauses) + ")") conditions.append("(" + " OR ".join(clauses) + ")")
# Build and execute SQL # Build and execute SQL
where_sql = "" sql = "SELECT * FROM files"
if conditions: if conditions:
where_sql = " WHERE " + " AND ".join(conditions) sql += " WHERE " + " AND ".join(conditions)
cursor.execute(sql, params)
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() raw_results = cursor.fetchall()
total_results = len(raw_results)
# Process results # Process results
results = [] results = []

View File

@ -623,14 +623,6 @@ footer .audio-player-container {
border-radius: 12px; border-radius: 12px;
background: linear-gradient(145deg, #ffffff, #f6f8fc); background: linear-gradient(145deg, #ffffff, #f6f8fc);
box-shadow: var(--card-shadow); 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 */ /* the filename overlay, centered at bottom */
@ -659,12 +651,11 @@ footer .audio-player-container {
.thumbnail { .thumbnail {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block;
object-fit: cover; object-fit: cover;
} }
.thumbnail-fallback { .thumbnail-fallback {
object-fit: cover; object-fit: contain;
background: #e5e5e5; background: #e5e5e5;
} }

View File

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

View File

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

View File

@ -1,14 +1,4 @@
(() => { (() => {
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 map = null;
let socket = null; let socket = null;
let activeDots = null; let activeDots = null;
@ -53,9 +43,9 @@
function updatePopup(dot) { function updatePopup(dot) {
const location = dot.connections[0]; const location = dot.connections[0];
const content = ` const content = `
<strong>${esc(location.city)}, ${esc(location.country)}</strong><br> <strong>${location.city}, ${location.country}</strong><br>
Connections: ${dot.mass}<br> Connections: ${dot.mass}<br>
Latest: ${esc(new Date(location.timestamp).toLocaleString())} Latest: ${new Date(location.timestamp).toLocaleString()}
`; `;
dot.marker.bindPopup(content); dot.marker.bindPopup(content);
} }
@ -226,13 +216,13 @@
tr.classList.remove('slide-in'), { once: true }); tr.classList.remove('slide-in'), { once: true });
} }
tr.innerHTML = ` tr.innerHTML = `
<td>${esc(record.timestamp)}</td> <td>${record.timestamp}</td>
<td>${esc(record.location)}</td> <td>${record.location}</td>
<td>${esc(record.user_agent)}</td> <td>${record.user_agent}</td>
<td>${esc(record.full_path)}</td> <td>${record.full_path}</td>
<td title="${esc(record.filesize)}">${esc(record.filesize_human || record.filesize)}</td> <td title="${record.filesize}">${record.filesize_human || record.filesize}</td>
<td>${esc(record.mime_typ)}</td> <td>${record.mime_typ}</td>
<td>${esc(record.cached)}</td> <td>${record.cached}</td>
`; `;
return tr; return tr;
} }

View File

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

View File

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

View File

@ -29,7 +29,6 @@ if (typeof currentGalleryIndex === "undefined") {
} }
let currentExifRequestId = 0; let currentExifRequestId = 0;
const previewRefreshInFlight = new Set();
function encodeGalleryPath(relUrl) { function encodeGalleryPath(relUrl) {
if (!relUrl) { if (!relUrl) {
@ -41,73 +40,6 @@ function encodeGalleryPath(relUrl) {
.join('/'); .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) { function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, (char) => ({ return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, (char) => ({
'&': '&amp;', '&': '&amp;',
@ -344,11 +276,6 @@ function showGalleryNav() {
}, 1000); }, 1000);
} }
function hideGalleryNav() {
clearTimeout(navHideTimer);
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.add('nav-hidden'));
}
function clamp(value, min, max) { function clamp(value, min, max) {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
@ -448,7 +375,6 @@ function showGalleryImage(relUrl) {
clearTimeout(loaderTimeout); clearTimeout(loaderTimeout);
loader.style.display = 'none'; loader.style.display = 'none';
updateFilenameDisplay(relUrl); updateFilenameDisplay(relUrl);
refreshBrowserThumbnailIfMissing(relUrl);
// Select all current images in the gallery stage // Select all current images in the gallery stage
const existingImages = stage.querySelectorAll('img'); const existingImages = stage.querySelectorAll('img');
@ -523,12 +449,8 @@ function showGalleryImage(relUrl) {
function preloadNextImage() { function preloadNextImage() {
if (currentGalleryIndex < currentGalleryImages.length - 1) { if (currentGalleryIndex < currentGalleryImages.length - 1) {
const nextRelUrl = currentGalleryImages[currentGalleryIndex + 1];
const nextImage = new Image(); const nextImage = new Image();
nextImage.onload = function () { nextImage.src = '/media/' + currentGalleryImages[currentGalleryIndex + 1];
refreshBrowserThumbnailIfMissing(nextRelUrl);
};
nextImage.src = '/media/' + nextRelUrl;
} }
} }
@ -781,6 +703,7 @@ function handlePinchStart(e) {
pinchStartMidY = (e.touches[0].pageY + e.touches[1].pageY) / 2; pinchStartMidY = (e.touches[0].pageY + e.touches[1].pageY) / 2;
pinchStartTranslateX = currentTranslateX; pinchStartTranslateX = currentTranslateX;
pinchStartTranslateY = currentTranslateY; pinchStartTranslateY = currentTranslateY;
showGalleryNav();
return; return;
} }
@ -813,11 +736,7 @@ function handlePinchMove(e) {
currentTranslateX = pinchStartTranslateX + (midX - pinchStartMidX); currentTranslateX = pinchStartTranslateX + (midX - pinchStartMidX);
currentTranslateY = pinchStartTranslateY + (midY - pinchStartMidY); currentTranslateY = pinchStartTranslateY + (midY - pinchStartMidY);
applyImageTransform(e.currentTarget); applyImageTransform(e.currentTarget);
if (currentScale > 1.01) {
showGalleryNav(); showGalleryNav();
} else {
hideGalleryNav();
}
return; return;
} }
@ -857,9 +776,6 @@ function handlePinchEnd(e) {
currentScale = 1; currentScale = 1;
currentTranslateX = 0; currentTranslateX = 0;
currentTranslateY = 0; currentTranslateY = 0;
applyImageTransform(image);
hideGalleryNav();
return;
} }
applyImageTransform(image); applyImageTransform(image);
showGalleryNav(); showGalleryNav();

View File

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

View File

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

View File

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

View File

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

View File

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