improve security
This commit is contained in:
parent
696a94288d
commit
68c15fb367
131
app.py
131
app.py
@ -1,5 +1,7 @@
|
|||||||
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')
|
||||||
@ -15,7 +17,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
|
from flask_socketio import SocketIO, emit, disconnect
|
||||||
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
|
||||||
@ -35,7 +37,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
|
from collections import OrderedDict, defaultdict, deque
|
||||||
|
|
||||||
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'])
|
||||||
@ -50,6 +52,76 @@ _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:
|
||||||
@ -76,6 +148,29 @@ 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))
|
||||||
@ -1154,11 +1249,21 @@ 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['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)
|
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
|
||||||
|
|
||||||
@ -1172,7 +1277,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 'admin' not in session and not session.get('admin'):
|
if not session.get('admin', False):
|
||||||
return "Unauthorized", 403
|
return "Unauthorized", 403
|
||||||
|
|
||||||
folder_config = auth.return_folder_config()
|
folder_config = auth.return_folder_config()
|
||||||
@ -1289,6 +1394,9 @@ 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:
|
||||||
@ -1299,11 +1407,16 @@ def handle_connect(auth=None):
|
|||||||
@socketio.on('disconnect')
|
@socketio.on('disconnect')
|
||||||
def handle_disconnect():
|
def handle_disconnect():
|
||||||
global clients_connected
|
global clients_connected
|
||||||
clients_connected -= 1
|
if clients_connected > 0:
|
||||||
|
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 = [
|
||||||
{
|
{
|
||||||
@ -1323,6 +1436,10 @@ 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:
|
||||||
|
|||||||
8
auth.py
8
auth.py
@ -75,10 +75,10 @@ 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) immediately return if dltoken is provided
|
# 1b) dltoken auth is only valid for /media access.
|
||||||
if args_dltoken:
|
# Token validation and file authorization are handled in serve_file.
|
||||||
if is_valid_token(args_dltoken):
|
if args_dltoken and request.endpoint == 'serve_file' and request.path.startswith('/media/'):
|
||||||
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
|
||||||
if 'valid_secrets' not in session:
|
if 'valid_secrets' not in session:
|
||||||
|
|||||||
57
search.py
57
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_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
|
||||||
@ -36,6 +68,9 @@ 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
|
||||||
@ -54,15 +89,16 @@ def searchcommand():
|
|||||||
fields = ['relative_path', 'filename']
|
fields = ['relative_path', 'filename']
|
||||||
|
|
||||||
for word in words:
|
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) })")
|
conditions.append(f"({ ' OR '.join(field_clauses) })")
|
||||||
for _ in fields:
|
for _ in fields:
|
||||||
params.append(f"%{word}%")
|
params.append(f"%{escaped_word}%")
|
||||||
|
|
||||||
# Category filter
|
# Category filter
|
||||||
if category:
|
if category:
|
||||||
conditions.append("filename LIKE ?")
|
conditions.append("filename LIKE ? ESCAPE '\\'")
|
||||||
params.append(f"%{category}%")
|
params.append(f"%{_escape_like(category)}%")
|
||||||
|
|
||||||
# Basefolder filter
|
# Basefolder filter
|
||||||
if allowed_basefolders:
|
if allowed_basefolders:
|
||||||
@ -107,12 +143,17 @@ def searchcommand():
|
|||||||
conditions.append("(" + " OR ".join(clauses) + ")")
|
conditions.append("(" + " OR ".join(clauses) + ")")
|
||||||
|
|
||||||
# Build and execute SQL
|
# Build and execute SQL
|
||||||
sql = "SELECT * FROM files"
|
where_sql = ""
|
||||||
if conditions:
|
if conditions:
|
||||||
sql += " WHERE " + " AND ".join(conditions)
|
where_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 = []
|
||||||
|
|||||||
162
static/app.js
162
static/app.js
@ -78,6 +78,33 @@ function encodeSubpath(subpath) {
|
|||||||
.join('/');
|
.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.
|
// 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));
|
||||||
@ -143,7 +170,7 @@ function jumpToTimecode(seconds, relUrl) {
|
|||||||
|
|
||||||
player.audio.addEventListener('loadedmetadata', seekWhenReady, { once: true });
|
player.audio.addEventListener('loadedmetadata', seekWhenReady, { once: true });
|
||||||
|
|
||||||
const trackLink = document.querySelector(`.play-file[data-url="${relUrl}"]`);
|
const trackLink = findPlayFileByUrl(relUrl);
|
||||||
if (trackLink) {
|
if (trackLink) {
|
||||||
trackLink.click();
|
trackLink.click();
|
||||||
} else {
|
} else {
|
||||||
@ -195,7 +222,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 = document.querySelector(`.play-file[data-url="${currentMusicFile.path}"]`);
|
const currentMusicFileElement = findPlayFileByUrl(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');
|
||||||
@ -205,10 +232,14 @@ 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) => {
|
||||||
breadcrumbHTML += `<a href="#" class="breadcrumb-link" data-path="${crumb.path}">${crumb.name}</a>`;
|
const crumbPath = String(crumb.path || '');
|
||||||
|
const crumbName = String(crumb.name || '');
|
||||||
|
breadcrumbHTML += `<a href="#" class="breadcrumb-link" data-path="${esc(crumbPath)}">${esc(crumbName)}</a>`;
|
||||||
if (index < data.breadcrumbs.length - 1) {
|
if (index < data.breadcrumbs.length - 1) {
|
||||||
breadcrumbHTML += `<span class="separator">​></span>`;
|
breadcrumbHTML += `<span class="separator">​></span>`;
|
||||||
}
|
}
|
||||||
@ -229,39 +260,47 @@ 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 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 += `
|
||||||
<div class="image-item">
|
<div class="image-item">
|
||||||
|
|
||||||
<a href="#" class="play-file image-link"
|
<a href="#" class="play-file image-link"
|
||||||
data-url="${file.path}"
|
data-url="${esc(filePath)}"
|
||||||
data-file-type="${file.file_type}">
|
data-file-type="${esc(fileType)}">
|
||||||
<img
|
<img
|
||||||
src="/media/${thumbUrl}"
|
src="/media/${thumbUrl}"
|
||||||
data-thumb-url="/media/${thumbUrl}"
|
data-thumb-url="/media/${thumbUrl}"
|
||||||
data-full-url="/media/${file.path}"
|
data-full-url="/media/${encodedPath}"
|
||||||
class="thumbnail"
|
class="thumbnail"
|
||||||
onload="handleThumbnailLoad(this)"
|
onload="handleThumbnailLoad(this)"
|
||||||
onerror="handleThumbnailError(this)"
|
onerror="handleThumbnailError(this)"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<span class="file-name">${file.name}</span>
|
<span class="file-name">${esc(fileName)}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
contentHTML += '</div>';
|
contentHTML += '</div>';
|
||||||
} else {
|
} else {
|
||||||
share_link = '';
|
let 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="${dir.path}"><i class="bi bi-gear"></i></a>`;
|
share_link = `<a href="#" class="create-share" data-url="${esc(dirPath)}"><i class="bi bi-gear"></i></a>`;
|
||||||
|
} else {
|
||||||
|
share_link = '';
|
||||||
}
|
}
|
||||||
contentHTML += `<div class="directory-item"><a href="#" class="directory-link" data-path="${dir.path}"><i class="bi bi-folder"></i> ${dir.name}</a>
|
contentHTML += `<div class="directory-item"><a href="#" class="directory-link" data-path="${esc(dirPath)}"><i class="bi bi-folder"></i> ${esc(dirName)}</a>
|
||||||
${share_link}
|
${share_link}
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
@ -278,15 +317,20 @@ 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="${dir.path}"><i class="bi bi-gear"></i></a>`;
|
share_link = `<a href="#" class="create-share" data-url="${esc(dirPath)}"><i class="bi bi-gear"></i></a>`;
|
||||||
|
} else {
|
||||||
|
share_link = '';
|
||||||
}
|
}
|
||||||
if (dir.path.includes('toplist')) {
|
let link_symbol = '<i class="bi bi-folder"></i>';
|
||||||
|
if (dirPath.includes('toplist')) {
|
||||||
link_symbol = '<i class="bi bi-star"></i>';
|
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="${dir.path}">${link_symbol} ${dir.name}</a>${share_link}</li>`;
|
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="${esc(dirPath)}">${link_symbol} ${esc(dirName)}</a>${share_link}</li>`;
|
||||||
});
|
});
|
||||||
contentHTML += '</ul>';
|
contentHTML += '</ul>';
|
||||||
}
|
}
|
||||||
@ -301,20 +345,24 @@ 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 (file.file_type === 'music') {
|
if (fileType === 'music') {
|
||||||
symbol = '<i class="bi bi-volume-up"></i>';
|
symbol = '<i class="bi bi-volume-up"></i>';
|
||||||
currentMusicFiles.push({ path: file.path, index: idx, title: file.name.replace('.mp3', '') });
|
currentMusicFiles.push({ path: filePath, index: idx, title: displayName });
|
||||||
} else if (file.file_type === 'image') {
|
} else if (fileType === 'image') {
|
||||||
symbol = '<i class="bi bi-image"></i>';
|
symbol = '<i class="bi bi-image"></i>';
|
||||||
} else if ((file.name || '').toLowerCase().endsWith('.sng')) {
|
} else if (fileName.toLowerCase().endsWith('.sng')) {
|
||||||
symbol = '<i class="bi bi-music-note-list"></i>';
|
symbol = '<i class="bi bi-music-note-list"></i>';
|
||||||
}
|
}
|
||||||
const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
|
const indexAttr = fileType === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
|
||||||
contentHTML += `<li class="file-item">
|
contentHTML += `<li class="file-item">
|
||||||
<a href="#" class="play-file"${indexAttr} data-url="${file.path}" data-file-type="${file.file_type}">${symbol} ${file.name.replace('.mp3', '')}</a>`;
|
<a href="#" class="play-file"${indexAttr} data-url="${esc(filePath)}" data-file-type="${esc(fileType)}">${symbol} ${esc(displayName)}</a>`;
|
||||||
if (file.has_transcript) {
|
if (file.has_transcript) {
|
||||||
contentHTML += `<a href="#" class="show-transcript" data-url="${file.transcript_url}" data-audio-url="${file.path}" title="Show Transcript"><i class="bi bi-journal-text"></i></a>`;
|
contentHTML += `<a href="#" class="show-transcript" data-url="${esc(file.transcript_url || '')}" data-audio-url="${esc(filePath)}" title="Show Transcript"><i class="bi bi-journal-text"></i></a>`;
|
||||||
}
|
}
|
||||||
contentHTML += `</li>`;
|
contentHTML += `</li>`;
|
||||||
});
|
});
|
||||||
@ -325,23 +373,27 @@ 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 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 += `
|
||||||
<div class="image-item">
|
<div class="image-item">
|
||||||
|
|
||||||
<a href="#" class="play-file image-link"
|
<a href="#" class="play-file image-link"
|
||||||
data-url="${file.path}"
|
data-url="${esc(filePath)}"
|
||||||
data-file-type="${file.file_type}">
|
data-file-type="${esc(fileType)}">
|
||||||
<img
|
<img
|
||||||
src="/media/${thumbUrl}"
|
src="/media/${thumbUrl}"
|
||||||
data-thumb-url="/media/${thumbUrl}"
|
data-thumb-url="/media/${thumbUrl}"
|
||||||
data-full-url="/media/${file.path}"
|
data-full-url="/media/${encodedPath}"
|
||||||
class="thumbnail"
|
class="thumbnail"
|
||||||
onload="handleThumbnailLoad(this)"
|
onload="handleThumbnailLoad(this)"
|
||||||
onerror="handleThumbnailError(this)"
|
onerror="handleThumbnailError(this)"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<span class="file-name">${file.name}</span>
|
<span class="file-name">${esc(fileName)}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
@ -414,11 +466,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">${data.error}</div>`;
|
document.getElementById('content').innerHTML = `<div class="alert alert-warning">${safeHtml(data.error)}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.playfile) {
|
if (data.playfile) {
|
||||||
const playFileLink = document.querySelector(`.play-file[data-url="${data.playfile}"]`);
|
const playFileLink = findPlayFileByUrl(data.playfile);
|
||||||
if (playFileLink) {
|
if (playFileLink) {
|
||||||
playFileLink.click();
|
playFileLink.click();
|
||||||
}
|
}
|
||||||
@ -430,7 +482,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>${error}</p>`;
|
document.getElementById('content').innerHTML = `<p>Error loading directory!</p><p>${safeHtml(String(error))}</p>`;
|
||||||
throw error; // Propagate the error
|
throw error; // Propagate the error
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -440,7 +492,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/' + 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.
|
// 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',
|
||||||
@ -462,7 +514,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/' + newPath : '/');
|
history.pushState({ subpath: newPath }, '', newPath ? '/path/' + encodeSubpath(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' });
|
||||||
});
|
});
|
||||||
@ -474,7 +526,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/' + newPath : '/');
|
history.pushState({ subpath: newPath }, '', newPath ? '/path/' + encodeSubpath(newPath) : '/');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -530,7 +582,8 @@ 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 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;
|
window.location.href = urlWithReq;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -565,12 +618,13 @@ document.querySelectorAll('.play-file').forEach(link => {
|
|||||||
.map(escapeRegExp)
|
.map(escapeRegExp)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (words.length) {
|
if (words.length) {
|
||||||
const regex = new RegExp(`(${words.join('|')})`, 'gi');
|
const regex = new RegExp(`(${words.join('|')})`, 'gi');
|
||||||
data = data.replace(regex, '<span class="highlight">$1</span>');
|
data = data.replace(regex, '<span class="highlight">$1</span>');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
document.getElementById('transcriptContent').innerHTML = marked.parse(data);
|
}
|
||||||
|
const transcriptHtml = marked.parse(data);
|
||||||
|
document.getElementById('transcriptContent').innerHTML = sanitizeMarkup(transcriptHtml);
|
||||||
attachTranscriptTimecodes(audioUrl);
|
attachTranscriptTimecodes(audioUrl);
|
||||||
document.getElementById('transcriptModal').style.display = 'block';
|
document.getElementById('transcriptModal').style.display = 'block';
|
||||||
})
|
})
|
||||||
@ -586,23 +640,9 @@ 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 url = '/create_token/' + this.getAttribute('data-url');
|
const rel = this.getAttribute('data-url') || '';
|
||||||
fetch(url)
|
const url = '/create_token/' + encodeSubpath(rel);
|
||||||
.then(response => {
|
window.open(url, '_blank', 'noopener');
|
||||||
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';
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -618,13 +658,7 @@ document.querySelectorAll('.play-file').forEach(link => {
|
|||||||
} // End of attachEventListeners function
|
} // End of attachEventListeners function
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
return (text || '').replace(/[&<>"']/g, (char) => ({
|
return safeHtml(text);
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
"'": '''
|
|
||||||
}[char]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSngLine(line) {
|
function formatSngLine(line) {
|
||||||
@ -925,7 +959,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 = document.querySelector(`.play-file[data-url=\"${nextFile.path}\"]`);
|
const nextLink = findPlayFileByUrl(nextFile.path);
|
||||||
if (nextLink) {
|
if (nextLink) {
|
||||||
nextLink.click();
|
nextLink.click();
|
||||||
}
|
}
|
||||||
@ -981,7 +1015,7 @@ function clearPendingFolderOpen() {
|
|||||||
|
|
||||||
function highlightPendingFile(filePath) {
|
function highlightPendingFile(filePath) {
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
const target = document.querySelector(`.play-file[data-url=\"${filePath}\"]`);
|
const target = findPlayFileByUrl(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' });
|
||||||
|
|||||||
@ -309,10 +309,12 @@
|
|||||||
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>${entry.title || ''} ${entry.details ? '<span class="details-indicator" title="Details vorhanden">ⓘ</span>' : ''}</td>
|
<td>${safeTitle} ${entry.details ? '<span class="details-indicator" title="Details vorhanden">ⓘ</span>' : ''}</td>
|
||||||
<td>${entry.location || ''}</td>
|
<td>${safeLocation}</td>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const rows = Array.from(tbody.querySelectorAll('tr.calendar-entry-row'));
|
const rows = Array.from(tbody.querySelectorAll('tr.calendar-entry-row'));
|
||||||
|
|||||||
@ -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 map = null;
|
||||||
let socket = null;
|
let socket = null;
|
||||||
let activeDots = null;
|
let activeDots = null;
|
||||||
@ -43,9 +53,9 @@
|
|||||||
function updatePopup(dot) {
|
function updatePopup(dot) {
|
||||||
const location = dot.connections[0];
|
const location = dot.connections[0];
|
||||||
const content = `
|
const content = `
|
||||||
<strong>${location.city}, ${location.country}</strong><br>
|
<strong>${esc(location.city)}, ${esc(location.country)}</strong><br>
|
||||||
Connections: ${dot.mass}<br>
|
Connections: ${dot.mass}<br>
|
||||||
Latest: ${new Date(location.timestamp).toLocaleString()}
|
Latest: ${esc(new Date(location.timestamp).toLocaleString())}
|
||||||
`;
|
`;
|
||||||
dot.marker.bindPopup(content);
|
dot.marker.bindPopup(content);
|
||||||
}
|
}
|
||||||
@ -216,13 +226,13 @@
|
|||||||
tr.classList.remove('slide-in'), { once: true });
|
tr.classList.remove('slide-in'), { once: true });
|
||||||
}
|
}
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${record.timestamp}</td>
|
<td>${esc(record.timestamp)}</td>
|
||||||
<td>${record.location}</td>
|
<td>${esc(record.location)}</td>
|
||||||
<td>${record.user_agent}</td>
|
<td>${esc(record.user_agent)}</td>
|
||||||
<td>${record.full_path}</td>
|
<td>${esc(record.full_path)}</td>
|
||||||
<td title="${record.filesize}">${record.filesize_human || record.filesize}</td>
|
<td title="${esc(record.filesize)}">${esc(record.filesize_human || record.filesize)}</td>
|
||||||
<td>${record.mime_typ}</td>
|
<td>${esc(record.mime_typ)}</td>
|
||||||
<td>${record.cached}</td>
|
<td>${esc(record.cached)}</td>
|
||||||
`;
|
`;
|
||||||
return tr;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,7 +77,19 @@
|
|||||||
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(', ');
|
||||||
headerTitle.innerHTML = `<strong>Ordner: ${folderNames}${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}</strong><i class="bi bi-chevron-down"></i>`;
|
const titleStrong = document.createElement('strong');
|
||||||
|
titleStrong.textContent = `Ordner: ${folderNames}`;
|
||||||
|
if (expired) {
|
||||||
|
const expiredMarker = document.createElement('span');
|
||||||
|
expiredMarker.className = 'text-danger fw-bold';
|
||||||
|
expiredMarker.textContent = ' ! abgelaufen !';
|
||||||
|
titleStrong.appendChild(document.createTextNode(' '));
|
||||||
|
titleStrong.appendChild(expiredMarker);
|
||||||
|
}
|
||||||
|
const chevron = document.createElement('i');
|
||||||
|
chevron.className = 'bi bi-chevron-down';
|
||||||
|
headerTitle.appendChild(titleStrong);
|
||||||
|
headerTitle.appendChild(chevron);
|
||||||
cardHeader.appendChild(headerTitle);
|
cardHeader.appendChild(headerTitle);
|
||||||
wrapper.appendChild(cardHeader);
|
wrapper.appendChild(cardHeader);
|
||||||
|
|
||||||
@ -231,7 +243,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=${rec.secret}`, '_self');
|
openButton.onclick = () => window.open(`/?secret=${encodeURIComponent(rec.secret)}`, '_self');
|
||||||
openButton.textContent = 'Link öffnen';
|
openButton.textContent = 'Link öffnen';
|
||||||
actions.appendChild(openButton);
|
actions.appendChild(openButton);
|
||||||
}
|
}
|
||||||
@ -239,7 +251,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=${rec.secret}`);
|
openButton.onclick = () => toClipboard(`${window.location.origin}/?secret=${encodeURIComponent(rec.secret)}`);
|
||||||
openButton.textContent = 'Link kopieren';
|
openButton.textContent = 'Link kopieren';
|
||||||
actions.appendChild(openButton);
|
actions.appendChild(openButton);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,3 +67,109 @@ function printToken() {
|
|||||||
popup.close();
|
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);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|||||||
@ -181,6 +181,8 @@ 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>';
|
||||||
@ -189,12 +191,14 @@ 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="${escapeHtml(path)}"><i class="bi bi-folder2"></i> ${escapeHtml(name)}</button>`
|
(match, name, path) => `<button class="folder-link-btn" data-folder-path="${esc(path)}"><i class="bi bi-folder2"></i> ${esc(name)}</button>`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Configure marked to allow all links
|
// Configure marked to allow all links
|
||||||
@ -202,23 +206,23 @@ function renderMessages() {
|
|||||||
breaks: true,
|
breaks: true,
|
||||||
gfm: true
|
gfm: true
|
||||||
});
|
});
|
||||||
const contentHtml = marked.parse(processedContent);
|
const contentHtml = sanitize(marked.parse(processedContent));
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="message-card" data-message-id="${message.id}">
|
<div class="message-card" data-message-id="${safeMessageId}">
|
||||||
<div class="message-header">
|
<div class="message-header">
|
||||||
<h2 class="message-title">${escapeHtml(message.title)}</h2>
|
<h2 class="message-title">${esc(message.title)}</h2>
|
||||||
<div class="message-datetime">${datetime}</div>
|
<div class="message-datetime">${esc(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(${message.id})" title="Bearbeiten">
|
<button class="btn-edit" onclick="editMessage(${safeMessageId})" title="Bearbeiten">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-delete" onclick="deleteMessage(${message.id})" title="Löschen">
|
<button class="btn-delete" onclick="deleteMessage(${safeMessageId})" title="Löschen">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -247,6 +251,7 @@ function formatDateTime(datetimeStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
|
if (window.escapeHtml) return window.escapeHtml(text);
|
||||||
const map = {
|
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) {
|
function openMessageModal(messageId = null) {
|
||||||
|
|||||||
@ -2,6 +2,21 @@ 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) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}[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) {
|
||||||
@ -17,36 +32,46 @@ 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 filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.');
|
const relativePath = String(file.relative_path || '');
|
||||||
const parentFolder = file.relative_path.split('/').slice(0, -1).join('/');
|
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 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' || (file.filename || '').toLowerCase().endsWith('.sng');
|
const isSng = (file.filetype || '').toLowerCase() === '.sng' || filename.toLowerCase().endsWith('.sng');
|
||||||
const encodedRelPath = encodeURI(file.relative_path);
|
const encodedRelPath = encodePath(relativePath);
|
||||||
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="${file.relative_path}" title="Liedtext öffnen"><i class="bi bi-journal-text"></i></a>`
|
? `<a href="#" class="open-sng-link" data-path="${esc(relativePath)}" title="Liedtext öffnen"><i class="bi bi-journal-text"></i></a>`
|
||||||
: `<a href="#" class="show-transcript" data-url="${fulltextUrl}" data-audio-url="${file.relative_path}" highlight="${file.query}"><i class="bi bi-journal-text"></i></a>`;
|
: `<a href="#" class="show-transcript" data-url="${esc(fulltextUrl)}" data-audio-url="${esc(relativePath)}" highlight="${esc(file.query || '')}"><i class="bi bi-journal-text"></i></a>`;
|
||||||
|
|
||||||
card.className = 'card';
|
card.className = 'card';
|
||||||
let fileAction = '';
|
let fileAction = '';
|
||||||
if (isSng) {
|
if (isSng) {
|
||||||
fileAction = `<button class="btn btn-light open-sng-btn" data-path="${file.relative_path}" style="width:100%;"><i class="bi bi-music-note-list"></i> ${filenameWithoutExtension}</button>`;
|
fileAction = `<button class="btn btn-light open-sng-btn" data-path="${esc(relativePath)}" style="width:100%;"><i class="bi bi-music-note-list"></i> ${esc(filenameWithoutExtension)}</button>`;
|
||||||
} else if (isAudio) {
|
} else if (isAudio) {
|
||||||
fileAction = `<button class="btn btn-light play-audio-btn" onclick="player.loadTrack('${file.relative_path}')" style="width:100%;"><i class="bi bi-volume-up"></i> ${filenameWithoutExtension}</button>`;
|
fileAction = `<button class="btn btn-light play-audio-btn" data-path="${esc(relativePath)}" style="width:100%;"><i class="bi bi-volume-up"></i> ${esc(filenameWithoutExtension)}</button>`;
|
||||||
} else {
|
} else {
|
||||||
fileAction = `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${file.filename}</a>`;
|
fileAction = `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${esc(filename)}</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hitcount = Number(file.hitcount);
|
||||||
|
const safeHitcount = Number.isFinite(hitcount) ? hitcount : 0;
|
||||||
|
const transcriptHits = Number(file.transcript_hits);
|
||||||
|
const safeTranscriptHits = Number.isFinite(transcriptHits) ? transcriptHits : 0;
|
||||||
|
const perfDate = (file.performance_date !== undefined && file.performance_date !== null)
|
||||||
|
? String(file.performance_date).trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
card.innerHTML = `
|
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="${parentFolder}" data-file="${file.relative_path}" style="width:100%;"><i class="bi bi-folder"></i> ${parentFolder || 'Ordner'}</button></p>
|
<p><button class="btn btn-light btn-sm folder-open-btn" data-folder="${esc(parentFolder)}" data-file="${esc(relativePath)}" style="width:100%;"><i class="bi bi-folder"></i> ${esc(parentFolder || 'Ordner')}</button></p>
|
||||||
<p class="card-text">Anzahl Downloads: ${file.hitcount}</p>
|
<p class="card-text">Anzahl Downloads: ${safeHitcount}</p>
|
||||||
${ (file.performance_date !== undefined && file.performance_date !== null && String(file.performance_date).trim() !== '') ? `<p class="card-text">Datum: ${file.performance_date}</p>` : ``}
|
${ perfDate !== '' ? `<p class="card-text">Datum: ${esc(perfDate)}</p>` : ``}
|
||||||
${ file.transcript_hits !== undefined ? `<p class="card-text">${fulltextLabel}: ${file.transcript_hits} ${fulltextAction}</p>` : ``}
|
${ file.transcript_hits !== undefined ? `<p class="card-text">${fulltextLabel}: ${safeTranscriptHits} ${fulltextAction}</p>` : ``}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
resultsDiv.appendChild(card);
|
resultsDiv.appendChild(card);
|
||||||
@ -59,6 +84,7 @@ function initSearch() {
|
|||||||
}
|
}
|
||||||
attachEventListeners();
|
attachEventListeners();
|
||||||
attachSearchFolderButtons();
|
attachSearchFolderButtons();
|
||||||
|
attachSearchAudioButtons();
|
||||||
attachSearchSngButtons();
|
attachSearchSngButtons();
|
||||||
attachSearchFulltextButtons();
|
attachSearchFulltextButtons();
|
||||||
} else {
|
} else {
|
||||||
@ -171,7 +197,19 @@ function initSearch() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
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 => {
|
.then(data => {
|
||||||
// Render the results
|
// Render the results
|
||||||
renderResults(data);
|
renderResults(data);
|
||||||
@ -189,6 +227,10 @@ 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') {
|
||||||
@ -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() {
|
function attachSearchSngButtons() {
|
||||||
document.querySelectorAll('.open-sng-btn').forEach(btn => {
|
document.querySelectorAll('.open-sng-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
@ -272,7 +326,10 @@ 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 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) {
|
if (target) {
|
||||||
target.classList.add('search-highlight');
|
target.classList.add('search-highlight');
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
|||||||
@ -61,6 +61,7 @@
|
|||||||
<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>
|
||||||
|
|||||||
@ -20,7 +20,10 @@
|
|||||||
<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>const admin_enabled = {{ admin_enabled | default(false) | tojson | safe }};</script>
|
<script>
|
||||||
|
const admin_enabled = {{ admin_enabled | default(false) | tojson | safe }};
|
||||||
|
window.CSRF_TOKEN = {{ csrf_token | tojson | safe }};
|
||||||
|
</script>
|
||||||
|
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
<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>
|
||||||
@ -63,6 +64,7 @@
|
|||||||
<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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user