diff --git a/app.py b/app.py index a86c5c0..c18c417 100755 --- a/app.py +++ b/app.py @@ -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/") @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 - clients_connected -= 1 + 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: diff --git a/auth.py b/auth.py index 06a0230..149aee4 100644 --- a/auth.py +++ b/auth.py @@ -75,10 +75,10 @@ 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): - return f(*args, **kwargs) + # 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 if 'valid_secrets' not in session: diff --git a/search.py b/search.py index 6afefc0..e3e47bb 100644 --- a/search.py +++ b/search.py @@ -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 = [] diff --git a/static/app.js b/static/app.js index f3ef662..1032f93 100644 --- a/static/app.js +++ b/static/app.js @@ -78,6 +78,33 @@ function encodeSubpath(subpath) { .join('/'); } +function safeHtml(value) { + if (window.escapeHtml) return window.escapeHtml(value); + return String(value ?? '').replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[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 += `${crumb.name}`; + const crumbPath = String(crumb.path || ''); + const crumbName = String(crumb.name || ''); + breadcrumbHTML += `${esc(crumbName)}`; if (index < data.breadcrumbs.length - 1) { breadcrumbHTML += `​>`; } @@ -229,39 +260,47 @@ function renderContent(data) { // Display thumbnails grid contentHTML += '
'; 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 += ` `; }); contentHTML += '
'; } 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 += '
'; 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 = ``; + share_link = ``; + } else { + share_link = ''; } - contentHTML += `
${dir.name} + contentHTML += `
${esc(dirName)} ${share_link}
`; }); @@ -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 = ``; + share_link = ``; + } else { + share_link = ''; } - if (dir.path.includes('toplist')) { + let link_symbol = ''; + if (dirPath.includes('toplist')) { link_symbol = ''; } else { link_symbol = ''; } - contentHTML += `
  • ${link_symbol} ${dir.name}${share_link}
  • `; + contentHTML += `
  • ${link_symbol} ${esc(dirName)}${share_link}
  • `; }); contentHTML += ''; } @@ -301,20 +345,24 @@ function renderContent(data) { if (nonImageFiles.length > 0) { contentHTML += '
      '; 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 = ''; - if (file.file_type === 'music') { + if (fileType === 'music') { symbol = ''; - 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 = ''; - } else if ((file.name || '').toLowerCase().endsWith('.sng')) { + } else if (fileName.toLowerCase().endsWith('.sng')) { symbol = ''; } - const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : ''; + const indexAttr = fileType === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : ''; contentHTML += `
    • - ${symbol} ${file.name.replace('.mp3', '')}`; + ${symbol} ${esc(displayName)}`; if (file.has_transcript) { - contentHTML += ``; + contentHTML += ``; } contentHTML += `
    • `; }); @@ -325,23 +373,27 @@ function renderContent(data) { if (imageFiles.length > 0) { contentHTML += '
      '; 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 += ` `; }); @@ -414,11 +466,11 @@ function loadDirectory(subpath) { if (data.breadcrumbs) { renderContent(data); } else if (data.error) { - document.getElementById('content').innerHTML = `
      ${data.error}
      `; + document.getElementById('content').innerHTML = `
      ${safeHtml(data.error)}
      `; 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 = `

      Error loading directory!

      ${error}

      `; + document.getElementById('content').innerHTML = `

      Error loading directory!

      ${safeHtml(String(error))}

      `; 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; } }); @@ -565,12 +618,13 @@ document.querySelectorAll('.play-file').forEach(link => { .map(escapeRegExp) .filter(Boolean); - if (words.length) { - const regex = new RegExp(`(${words.join('|')})`, 'gi'); - data = data.replace(regex, '$1'); - } + if (words.length) { + const regex = new RegExp(`(${words.join('|')})`, 'gi'); + data = data.replace(regex, '$1'); } - 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 = "

      You can't share this.

      "; - 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) => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }[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' }); diff --git a/static/calendar.js b/static/calendar.js index ce9e09f..b5fb053 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -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 = ` ${timeValue || '-'} - ${entry.title || ''} ${entry.details ? '' : ''} - ${entry.location || ''} + ${safeTitle} ${entry.details ? '' : ''} + ${safeLocation} `; const rows = Array.from(tbody.querySelectorAll('tr.calendar-entry-row')); diff --git a/static/connections.js b/static/connections.js index 9f260be..a346f08 100644 --- a/static/connections.js +++ b/static/connections.js @@ -1,4 +1,14 @@ (() => { + const esc = (value) => { + if (window.escapeHtml) return window.escapeHtml(value); + return String(value ?? '').replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[char])); + }; let map = null; let socket = null; let activeDots = null; @@ -43,9 +53,9 @@ function updatePopup(dot) { const location = dot.connections[0]; const content = ` - ${location.city}, ${location.country}
      + ${esc(location.city)}, ${esc(location.country)}
      Connections: ${dot.mass}
      - 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 = ` - ${record.timestamp} - ${record.location} - ${record.user_agent} - ${record.full_path} - ${record.filesize_human || record.filesize} - ${record.mime_typ} - ${record.cached} + ${esc(record.timestamp)} + ${esc(record.location)} + ${esc(record.user_agent)} + ${esc(record.full_path)} + ${esc(record.filesize_human || record.filesize)} + ${esc(record.mime_typ)} + ${esc(record.cached)} `; return tr; } diff --git a/static/folder_secret_config_editor.js b/static/folder_secret_config_editor.js index bf2bef2..6b8c6d7 100644 --- a/static/folder_secret_config_editor.js +++ b/static/folder_secret_config_editor.js @@ -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 = `Ordner: ${folderNames}${expired ? ' ! abgelaufen !' : ''}`; + 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); } diff --git a/static/functions.js b/static/functions.js index 581d287..c49dc4c 100644 --- a/static/functions.js +++ b/static/functions.js @@ -67,3 +67,109 @@ function printToken() { popup.close(); }; } + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[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); + }; +})(); diff --git a/static/messages.js b/static/messages.js index e4a766d..54ca36a 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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 = '
      Keine Nachrichten vorhanden
      '; @@ -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) => `` + (match, name, path) => `` ); // 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 += ` -
      +
      -

      ${escapeHtml(message.title)}

      -
      ${datetime}
      +

      ${esc(message.title)}

      +
      ${esc(datetime)}
      ${contentHtml}
      ${admin_enabled ? `
      - -
      @@ -247,6 +251,7 @@ function formatDateTime(datetimeStr) { } function escapeHtml(text) { + if (window.escapeHtml) return window.escapeHtml(text); const map = { '&': '&', '<': '<', @@ -254,7 +259,7 @@ function escapeHtml(text) { '"': '"', "'": ''' }; - return text.replace(/[&<>"']/g, m => map[m]); + return String(text ?? '').replace(/[&<>"']/g, m => map[m]); } function openMessageModal(messageId = null) { diff --git a/static/search.js b/static/search.js index d385c96..d3ef862 100644 --- a/static/search.js +++ b/static/search.js @@ -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) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[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' - ? `` - : ``; + ? `` + : ``; card.className = 'card'; let fileAction = ''; if (isSng) { - fileAction = ``; + fileAction = ``; } else if (isAudio) { - fileAction = ``; + fileAction = ``; } else { - fileAction = ` ${file.filename}`; + fileAction = ` ${esc(filename)}`; } + 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 = `

      ${fileAction}

      -

      -

      Anzahl Downloads: ${file.hitcount}

      - ${ (file.performance_date !== undefined && file.performance_date !== null && String(file.performance_date).trim() !== '') ? `

      Datum: ${file.performance_date}

      ` : ``} - ${ file.transcript_hits !== undefined ? `

      ${fulltextLabel}: ${file.transcript_hits} ${fulltextAction}

      ` : ``} +

      +

      Anzahl Downloads: ${safeHitcount}

      + ${ perfDate !== '' ? `

      Datum: ${esc(perfDate)}

      ` : ``} + ${ file.transcript_hits !== undefined ? `

      ${fulltextLabel}: ${safeTranscriptHits} ${fulltextAction}

      ` : ``}
      `; 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 = `
      ${esc(error.message || 'Search failed')}
      `; + } }); // 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' }); diff --git a/templates/app.html b/templates/app.html index 26803c3..e414f93 100644 --- a/templates/app.html +++ b/templates/app.html @@ -61,6 +61,7 @@
      +
      diff --git a/templates/base.html b/templates/base.html index e5c2cec..f0cb54b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -20,7 +20,10 @@ - + {% block head_extra %}{% endblock %} diff --git a/templates/mylinks.html b/templates/mylinks.html index 8e97f00..c6e3d1b 100644 --- a/templates/mylinks.html +++ b/templates/mylinks.html @@ -28,6 +28,7 @@
      + @@ -63,6 +64,7 @@
      +