Compare commits
No commits in common. "f6010b065d41510a85082e8eede46308132a3052" and "3c7cb11f02f2e146de151926c5f3f154165be567" have entirely different histories.
f6010b065d
...
3c7cb11f02
129
app.py
129
app.py
@ -1,7 +1,5 @@
|
||||
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')
|
||||
@ -17,7 +15,7 @@ import mimetypes
|
||||
from datetime import datetime, date, timedelta
|
||||
import diskcache
|
||||
import threading
|
||||
from flask_socketio import SocketIO, emit, disconnect
|
||||
from flask_socketio import SocketIO, emit
|
||||
import geoip2.database
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urlparse, unquote
|
||||
@ -37,7 +35,7 @@ import helperfunctions as hf
|
||||
import search_db_analyzer as sdb
|
||||
import fnmatch
|
||||
import openpyxl
|
||||
from collections import OrderedDict, defaultdict, deque
|
||||
from collections import OrderedDict
|
||||
|
||||
app_config = auth.return_app_config()
|
||||
BASE_DIR = os.path.realpath(app_config['BASE_DIR'])
|
||||
@ -52,76 +50,6 @@ _logged_request_ids = OrderedDict()
|
||||
_logged_request_ids_lock = threading.Lock()
|
||||
_LOGGED_REQUEST_IDS_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:
|
||||
@ -148,29 +76,6 @@ 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))
|
||||
@ -1249,21 +1154,11 @@ def media_exif(subpath):
|
||||
@app.route("/transcript/<path:subpath>")
|
||||
@auth.require_secret
|
||||
def get_transcript(subpath):
|
||||
|
||||
root, *relative_parts = subpath.split('/')
|
||||
base_path = session.get('folders', {}).get(root)
|
||||
if not base_path:
|
||||
return "Transcription not found", 404
|
||||
|
||||
base_path = session['folders'][root]
|
||||
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
|
||||
|
||||
@ -1277,7 +1172,7 @@ def get_transcript(subpath):
|
||||
def create_token(subpath):
|
||||
scheme = request.scheme # current scheme (http or https)
|
||||
host = request.host
|
||||
if not session.get('admin', False):
|
||||
if 'admin' not in session and not session.get('admin'):
|
||||
return "Unauthorized", 403
|
||||
|
||||
folder_config = auth.return_folder_config()
|
||||
@ -1394,9 +1289,6 @@ 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:
|
||||
@ -1407,16 +1299,11 @@ def handle_connect(auth=None):
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
global clients_connected
|
||||
if clients_connected > 0:
|
||||
clients_connected -= 1
|
||||
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 = [
|
||||
{
|
||||
@ -1436,10 +1323,6 @@ 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:
|
||||
|
||||
8
auth.py
8
auth.py
@ -75,10 +75,10 @@ def require_secret(f):
|
||||
args_token = request.args.get('token')
|
||||
args_dltoken = request.args.get('dltoken')
|
||||
|
||||
# 1b) dltoken auth is only valid for /media access.
|
||||
# Token validation and file authorization are handled in serve_file.
|
||||
if args_dltoken and request.endpoint == 'serve_file' and request.path.startswith('/media/'):
|
||||
return f(*args, **kwargs)
|
||||
# 1b) immediately return if dltoken is provided
|
||||
if args_dltoken:
|
||||
if is_valid_token(args_dltoken):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# 2) Initialize 'valid_secrets' in the session if missing
|
||||
if 'valid_secrets' not in session:
|
||||
|
||||
57
search.py
57
search.py
@ -23,44 +23,12 @@ 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
|
||||
@ -68,9 +36,6 @@ 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
|
||||
@ -89,16 +54,15 @@ def searchcommand():
|
||||
fields = ['relative_path', 'filename']
|
||||
|
||||
for word in words:
|
||||
escaped_word = _escape_like(word)
|
||||
field_clauses = [f"{f} LIKE ? ESCAPE '\\'" for f in fields]
|
||||
field_clauses = [f"{f} LIKE ?" for f in fields]
|
||||
conditions.append(f"({ ' OR '.join(field_clauses) })")
|
||||
for _ in fields:
|
||||
params.append(f"%{escaped_word}%")
|
||||
params.append(f"%{word}%")
|
||||
|
||||
# Category filter
|
||||
if category:
|
||||
conditions.append("filename LIKE ? ESCAPE '\\'")
|
||||
params.append(f"%{_escape_like(category)}%")
|
||||
conditions.append("filename LIKE ?")
|
||||
params.append(f"%{category}%")
|
||||
|
||||
# Basefolder filter
|
||||
if allowed_basefolders:
|
||||
@ -143,17 +107,12 @@ def searchcommand():
|
||||
conditions.append("(" + " OR ".join(clauses) + ")")
|
||||
|
||||
# Build and execute SQL
|
||||
where_sql = ""
|
||||
sql = "SELECT * FROM files"
|
||||
if conditions:
|
||||
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])
|
||||
sql += " WHERE " + " AND ".join(conditions)
|
||||
cursor.execute(sql, params)
|
||||
raw_results = cursor.fetchall()
|
||||
total_results = len(raw_results)
|
||||
|
||||
# Process results
|
||||
results = []
|
||||
|
||||
@ -623,14 +623,6 @@ footer .audio-player-container {
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(145deg, #ffffff, #f6f8fc);
|
||||
box-shadow: var(--card-shadow);
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.image-item .image-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* the filename overlay, centered at bottom */
|
||||
@ -659,12 +651,11 @@ footer .audio-player-container {
|
||||
.thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumbnail-fallback {
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
|
||||
162
static/app.js
162
static/app.js
@ -78,33 +78,6 @@ 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));
|
||||
@ -170,7 +143,7 @@ function jumpToTimecode(seconds, relUrl) {
|
||||
|
||||
player.audio.addEventListener('loadedmetadata', seekWhenReady, { once: true });
|
||||
|
||||
const trackLink = findPlayFileByUrl(relUrl);
|
||||
const trackLink = document.querySelector(`.play-file[data-url="${relUrl}"]`);
|
||||
if (trackLink) {
|
||||
trackLink.click();
|
||||
} else {
|
||||
@ -222,7 +195,7 @@ function paintFile() {
|
||||
if (currentTrackPath) {
|
||||
const currentMusicFile = currentMusicFiles.find(file => file.path === currentTrackPath);
|
||||
if (currentMusicFile) {
|
||||
const currentMusicFileElement = findPlayFileByUrl(currentMusicFile.path);
|
||||
const currentMusicFileElement = document.querySelector(`.play-file[data-url="${currentMusicFile.path}"]`);
|
||||
if (currentMusicFileElement) {
|
||||
const fileItem = currentMusicFileElement.closest('.file-item');
|
||||
fileItem.classList.add('currently-playing');
|
||||
@ -232,14 +205,10 @@ function paintFile() {
|
||||
}
|
||||
|
||||
function renderContent(data) {
|
||||
const esc = safeHtml;
|
||||
|
||||
// Render breadcrumbs
|
||||
let breadcrumbHTML = '';
|
||||
data.breadcrumbs.forEach((crumb, index) => {
|
||||
const crumbPath = String(crumb.path || '');
|
||||
const crumbName = String(crumb.name || '');
|
||||
breadcrumbHTML += `<a href="#" class="breadcrumb-link" data-path="${esc(crumbPath)}">${esc(crumbName)}</a>`;
|
||||
breadcrumbHTML += `<a href="#" class="breadcrumb-link" data-path="${crumb.path}">${crumb.name}</a>`;
|
||||
if (index < data.breadcrumbs.length - 1) {
|
||||
breadcrumbHTML += `<span class="separator">​></span>`;
|
||||
}
|
||||
@ -260,47 +229,39 @@ function renderContent(data) {
|
||||
// Display thumbnails grid
|
||||
contentHTML += '<div class="images-grid">';
|
||||
imageFiles.forEach(file => {
|
||||
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`;
|
||||
const thumbUrl = `${file.path}?thumbnail=true`;
|
||||
contentHTML += `
|
||||
<div class="image-item">
|
||||
|
||||
<a href="#" class="play-file image-link"
|
||||
data-url="${esc(filePath)}"
|
||||
data-file-type="${esc(fileType)}">
|
||||
data-url="${file.path}"
|
||||
data-file-type="${file.file_type}">
|
||||
<img
|
||||
src="/media/${thumbUrl}"
|
||||
data-thumb-url="/media/${thumbUrl}"
|
||||
data-full-url="/media/${encodedPath}"
|
||||
data-full-url="/media/${file.path}"
|
||||
class="thumbnail"
|
||||
onload="handleThumbnailLoad(this)"
|
||||
onerror="handleThumbnailError(this)"
|
||||
/>
|
||||
</a>
|
||||
<span class="file-name">${esc(fileName)}</span>
|
||||
<span class="file-name">${file.name}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
contentHTML += '</div>';
|
||||
} else {
|
||||
let share_link = '';
|
||||
share_link = '';
|
||||
// Render directories normally
|
||||
if (data.directories.length > 0) {
|
||||
const areAllShort = data.directories.every(dir => dir.name.length <= 10) && data.files.length === 0;
|
||||
if (areAllShort && data.breadcrumbs.length !== 1) {
|
||||
contentHTML += '<div class="directories-grid">';
|
||||
data.directories.forEach(dir => {
|
||||
const dirPath = String(dir.path || '');
|
||||
const dirName = String(dir.name || '');
|
||||
if (admin_enabled && data.breadcrumbs.length != 1 && dir.share) {
|
||||
share_link = `<a href="#" class="create-share" data-url="${esc(dirPath)}"><i class="bi bi-gear"></i></a>`;
|
||||
} else {
|
||||
share_link = '';
|
||||
share_link = `<a href="#" class="create-share" data-url="${dir.path}"><i class="bi bi-gear"></i></a>`;
|
||||
}
|
||||
contentHTML += `<div class="directory-item"><a href="#" class="directory-link" data-path="${esc(dirPath)}"><i class="bi bi-folder"></i> ${esc(dirName)}</a>
|
||||
contentHTML += `<div class="directory-item"><a href="#" class="directory-link" data-path="${dir.path}"><i class="bi bi-folder"></i> ${dir.name}</a>
|
||||
${share_link}
|
||||
</div>`;
|
||||
});
|
||||
@ -317,20 +278,15 @@ function renderContent(data) {
|
||||
}
|
||||
|
||||
data.directories.forEach(dir => {
|
||||
const dirPath = String(dir.path || '');
|
||||
const dirName = String(dir.name || '');
|
||||
if (admin_enabled && data.breadcrumbs.length != 1 && dir.share) {
|
||||
share_link = `<a href="#" class="create-share" data-url="${esc(dirPath)}"><i class="bi bi-gear"></i></a>`;
|
||||
} else {
|
||||
share_link = '';
|
||||
share_link = `<a href="#" class="create-share" data-url="${dir.path}"><i class="bi bi-gear"></i></a>`;
|
||||
}
|
||||
let link_symbol = '<i class="bi bi-folder"></i>';
|
||||
if (dirPath.includes('toplist')) {
|
||||
if (dir.path.includes('toplist')) {
|
||||
link_symbol = '<i class="bi bi-star"></i>';
|
||||
} else {
|
||||
link_symbol = '<i class="bi bi-folder"></i>';
|
||||
}
|
||||
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="${esc(dirPath)}">${link_symbol} ${esc(dirName)}</a>${share_link}</li>`;
|
||||
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="${dir.path}">${link_symbol} ${dir.name}</a>${share_link}</li>`;
|
||||
});
|
||||
contentHTML += '</ul>';
|
||||
}
|
||||
@ -345,24 +301,20 @@ function renderContent(data) {
|
||||
if (nonImageFiles.length > 0) {
|
||||
contentHTML += '<ul>';
|
||||
nonImageFiles.forEach((file, idx) => {
|
||||
const filePath = String(file.path || '');
|
||||
const fileType = String(file.file_type || '');
|
||||
const fileName = String(file.name || '');
|
||||
const displayName = fileName.replace('.mp3', '');
|
||||
let symbol = '<i class="bi bi-file-earmark"></i>';
|
||||
if (fileType === 'music') {
|
||||
if (file.file_type === 'music') {
|
||||
symbol = '<i class="bi bi-volume-up"></i>';
|
||||
currentMusicFiles.push({ path: filePath, index: idx, title: displayName });
|
||||
} else if (fileType === 'image') {
|
||||
currentMusicFiles.push({ path: file.path, index: idx, title: file.name.replace('.mp3', '') });
|
||||
} else if (file.file_type === 'image') {
|
||||
symbol = '<i class="bi bi-image"></i>';
|
||||
} else if (fileName.toLowerCase().endsWith('.sng')) {
|
||||
} else if ((file.name || '').toLowerCase().endsWith('.sng')) {
|
||||
symbol = '<i class="bi bi-music-note-list"></i>';
|
||||
}
|
||||
const indexAttr = fileType === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
|
||||
const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
|
||||
contentHTML += `<li class="file-item">
|
||||
<a href="#" class="play-file"${indexAttr} data-url="${esc(filePath)}" data-file-type="${esc(fileType)}">${symbol} ${esc(displayName)}</a>`;
|
||||
<a href="#" class="play-file"${indexAttr} data-url="${file.path}" data-file-type="${file.file_type}">${symbol} ${file.name.replace('.mp3', '')}</a>`;
|
||||
if (file.has_transcript) {
|
||||
contentHTML += `<a href="#" class="show-transcript" data-url="${esc(file.transcript_url || '')}" data-audio-url="${esc(filePath)}" title="Show Transcript"><i class="bi bi-journal-text"></i></a>`;
|
||||
contentHTML += `<a href="#" class="show-transcript" data-url="${file.transcript_url}" data-audio-url="${file.path}" title="Show Transcript"><i class="bi bi-journal-text"></i></a>`;
|
||||
}
|
||||
contentHTML += `</li>`;
|
||||
});
|
||||
@ -373,27 +325,23 @@ function renderContent(data) {
|
||||
if (imageFiles.length > 0) {
|
||||
contentHTML += '<div class="images-grid">';
|
||||
imageFiles.forEach(file => {
|
||||
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`;
|
||||
const thumbUrl = `${file.path}?thumbnail=true`;
|
||||
contentHTML += `
|
||||
<div class="image-item">
|
||||
|
||||
<a href="#" class="play-file image-link"
|
||||
data-url="${esc(filePath)}"
|
||||
data-file-type="${esc(fileType)}">
|
||||
data-url="${file.path}"
|
||||
data-file-type="${file.file_type}">
|
||||
<img
|
||||
src="/media/${thumbUrl}"
|
||||
data-thumb-url="/media/${thumbUrl}"
|
||||
data-full-url="/media/${encodedPath}"
|
||||
data-full-url="/media/${file.path}"
|
||||
class="thumbnail"
|
||||
onload="handleThumbnailLoad(this)"
|
||||
onerror="handleThumbnailError(this)"
|
||||
/>
|
||||
</a>
|
||||
<span class="file-name">${esc(fileName)}</span>
|
||||
<span class="file-name">${file.name}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
@ -466,11 +414,11 @@ function loadDirectory(subpath) {
|
||||
if (data.breadcrumbs) {
|
||||
renderContent(data);
|
||||
} else if (data.error) {
|
||||
document.getElementById('content').innerHTML = `<div class="alert alert-warning">${safeHtml(data.error)}</div>`;
|
||||
document.getElementById('content').innerHTML = `<div class="alert alert-warning">${data.error}</div>`;
|
||||
return;
|
||||
}
|
||||
if (data.playfile) {
|
||||
const playFileLink = findPlayFileByUrl(data.playfile);
|
||||
const playFileLink = document.querySelector(`.play-file[data-url="${data.playfile}"]`);
|
||||
if (playFileLink) {
|
||||
playFileLink.click();
|
||||
}
|
||||
@ -482,7 +430,7 @@ function loadDirectory(subpath) {
|
||||
clearTimeout(spinnerTimer);
|
||||
hideSpinner();
|
||||
console.error('Error loading directory:', error);
|
||||
document.getElementById('content').innerHTML = `<p>Error loading directory!</p><p>${safeHtml(String(error))}</p>`;
|
||||
document.getElementById('content').innerHTML = `<p>Error loading directory!</p><p>${error}</p>`;
|
||||
throw error; // Propagate the error
|
||||
});
|
||||
}
|
||||
@ -492,7 +440,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/' + encodeSubpath(nextFile.path);
|
||||
const nextMediaUrl = '/media/' + nextFile.path;
|
||||
// Use a HEAD request so that the backend reads and caches the file without returning the full content.
|
||||
fetch(nextMediaUrl, {
|
||||
method: 'HEAD',
|
||||
@ -514,7 +462,7 @@ function attachEventListeners() {
|
||||
event.preventDefault();
|
||||
const newPath = this.getAttribute('data-path');
|
||||
loadDirectory(newPath);
|
||||
history.pushState({ subpath: newPath }, '', newPath ? '/path/' + encodeSubpath(newPath) : '/');
|
||||
history.pushState({ subpath: newPath }, '', newPath ? '/path/' + newPath : '/');
|
||||
// Scroll to the top of the page after click:
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
@ -526,7 +474,7 @@ function attachEventListeners() {
|
||||
event.preventDefault();
|
||||
const newPath = this.getAttribute('data-path');
|
||||
loadDirectory(newPath);
|
||||
history.pushState({ subpath: newPath }, '', newPath ? '/path/' + encodeSubpath(newPath) : '/');
|
||||
history.pushState({ subpath: newPath }, '', newPath ? '/path/' + newPath : '/');
|
||||
});
|
||||
});
|
||||
|
||||
@ -582,8 +530,7 @@ document.querySelectorAll('.play-file').forEach(link => {
|
||||
} else {
|
||||
// serve like a download
|
||||
const reqId = crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2));
|
||||
const encodedRelUrl = encodeSubpath(relUrl);
|
||||
const urlWithReq = `/media/${encodedRelUrl}${encodedRelUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(reqId)}`;
|
||||
const urlWithReq = `/media/${relUrl}${relUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(reqId)}`;
|
||||
window.location.href = urlWithReq;
|
||||
}
|
||||
});
|
||||
@ -618,13 +565,12 @@ document.querySelectorAll('.play-file').forEach(link => {
|
||||
.map(escapeRegExp)
|
||||
.filter(Boolean);
|
||||
|
||||
if (words.length) {
|
||||
const regex = new RegExp(`(${words.join('|')})`, 'gi');
|
||||
data = data.replace(regex, '<span class="highlight">$1</span>');
|
||||
if (words.length) {
|
||||
const regex = new RegExp(`(${words.join('|')})`, 'gi');
|
||||
data = data.replace(regex, '<span class="highlight">$1</span>');
|
||||
}
|
||||
}
|
||||
}
|
||||
const transcriptHtml = marked.parse(data);
|
||||
document.getElementById('transcriptContent').innerHTML = sanitizeMarkup(transcriptHtml);
|
||||
document.getElementById('transcriptContent').innerHTML = marked.parse(data);
|
||||
attachTranscriptTimecodes(audioUrl);
|
||||
document.getElementById('transcriptModal').style.display = 'block';
|
||||
})
|
||||
@ -640,9 +586,23 @@ document.querySelectorAll('.play-file').forEach(link => {
|
||||
document.querySelectorAll('.create-share').forEach(link => {
|
||||
link.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
const rel = this.getAttribute('data-url') || '';
|
||||
const url = '/create_token/' + encodeSubpath(rel);
|
||||
window.open(url, '_blank', 'noopener');
|
||||
const url = '/create_token/' + this.getAttribute('data-url');
|
||||
fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(data => {
|
||||
document.getElementById('transcriptContent').innerHTML = data;
|
||||
document.getElementById('transcriptModal').style.display = 'block';
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error creating share:', error);
|
||||
document.getElementById('transcriptContent').innerHTML = "<p>You can't share this.</p>";
|
||||
document.getElementById('transcriptModal').style.display = 'block';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -658,7 +618,13 @@ document.querySelectorAll('.play-file').forEach(link => {
|
||||
} // End of attachEventListeners function
|
||||
|
||||
function escapeHtml(text) {
|
||||
return safeHtml(text);
|
||||
return (text || '').replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function formatSngLine(line) {
|
||||
@ -959,7 +925,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 = findPlayFileByUrl(nextFile.path);
|
||||
const nextLink = document.querySelector(`.play-file[data-url=\"${nextFile.path}\"]`);
|
||||
if (nextLink) {
|
||||
nextLink.click();
|
||||
}
|
||||
@ -1015,7 +981,7 @@ function clearPendingFolderOpen() {
|
||||
|
||||
function highlightPendingFile(filePath) {
|
||||
if (!filePath) return;
|
||||
const target = findPlayFileByUrl(filePath);
|
||||
const target = document.querySelector(`.play-file[data-url=\"${filePath}\"]`);
|
||||
if (target) {
|
||||
target.classList.add('search-highlight');
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
@ -309,12 +309,10 @@
|
||||
row.dataset.location = entry.location || '';
|
||||
row.dataset.details = entry.details || '';
|
||||
row.className = 'calendar-entry-row';
|
||||
const safeTitle = escapeHtml(entry.title || '');
|
||||
const safeLocation = escapeHtml(entry.location || '');
|
||||
row.innerHTML = `
|
||||
<td>${timeValue || '-'}</td>
|
||||
<td>${safeTitle} ${entry.details ? '<span class="details-indicator" title="Details vorhanden">ⓘ</span>' : ''}</td>
|
||||
<td>${safeLocation}</td>
|
||||
<td>${entry.title || ''} ${entry.details ? '<span class="details-indicator" title="Details vorhanden">ⓘ</span>' : ''}</td>
|
||||
<td>${entry.location || ''}</td>
|
||||
`;
|
||||
|
||||
const rows = Array.from(tbody.querySelectorAll('tr.calendar-entry-row'));
|
||||
|
||||
@ -1,14 +1,4 @@
|
||||
(() => {
|
||||
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;
|
||||
@ -53,9 +43,9 @@
|
||||
function updatePopup(dot) {
|
||||
const location = dot.connections[0];
|
||||
const content = `
|
||||
<strong>${esc(location.city)}, ${esc(location.country)}</strong><br>
|
||||
<strong>${location.city}, ${location.country}</strong><br>
|
||||
Connections: ${dot.mass}<br>
|
||||
Latest: ${esc(new Date(location.timestamp).toLocaleString())}
|
||||
Latest: ${new Date(location.timestamp).toLocaleString()}
|
||||
`;
|
||||
dot.marker.bindPopup(content);
|
||||
}
|
||||
@ -226,13 +216,13 @@
|
||||
tr.classList.remove('slide-in'), { once: true });
|
||||
}
|
||||
tr.innerHTML = `
|
||||
<td>${esc(record.timestamp)}</td>
|
||||
<td>${esc(record.location)}</td>
|
||||
<td>${esc(record.user_agent)}</td>
|
||||
<td>${esc(record.full_path)}</td>
|
||||
<td title="${esc(record.filesize)}">${esc(record.filesize_human || record.filesize)}</td>
|
||||
<td>${esc(record.mime_typ)}</td>
|
||||
<td>${esc(record.cached)}</td>
|
||||
<td>${record.timestamp}</td>
|
||||
<td>${record.location}</td>
|
||||
<td>${record.user_agent}</td>
|
||||
<td>${record.full_path}</td>
|
||||
<td title="${record.filesize}">${record.filesize_human || record.filesize}</td>
|
||||
<td>${record.mime_typ}</td>
|
||||
<td>${record.cached}</td>
|
||||
`;
|
||||
return tr;
|
||||
}
|
||||
|
||||
@ -77,19 +77,7 @@
|
||||
const headerTitle = document.createElement('div');
|
||||
headerTitle.className = 'd-flex justify-content-between align-items-center';
|
||||
const folderNames = rec.folders.map(f => f.foldername).join(', ');
|
||||
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);
|
||||
headerTitle.innerHTML = `<strong>Ordner: ${folderNames}${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}</strong><i class="bi bi-chevron-down"></i>`;
|
||||
cardHeader.appendChild(headerTitle);
|
||||
wrapper.appendChild(cardHeader);
|
||||
|
||||
@ -243,7 +231,7 @@
|
||||
if (!isEdit) {
|
||||
const openButton = document.createElement('button');
|
||||
openButton.className = 'btn btn-secondary btn-sm me-2';
|
||||
openButton.onclick = () => window.open(`/?secret=${encodeURIComponent(rec.secret)}`, '_self');
|
||||
openButton.onclick = () => window.open(`/?secret=${rec.secret}`, '_self');
|
||||
openButton.textContent = 'Link öffnen';
|
||||
actions.appendChild(openButton);
|
||||
}
|
||||
@ -251,7 +239,7 @@
|
||||
if (!isEdit) {
|
||||
const openButton = document.createElement('button');
|
||||
openButton.className = 'btn btn-secondary btn-sm me-2';
|
||||
openButton.onclick = () => toClipboard(`${window.location.origin}/?secret=${encodeURIComponent(rec.secret)}`);
|
||||
openButton.onclick = () => toClipboard(`${window.location.origin}/?secret=${rec.secret}`);
|
||||
openButton.textContent = 'Link kopieren';
|
||||
actions.appendChild(openButton);
|
||||
}
|
||||
|
||||
@ -67,109 +67,3 @@ 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);
|
||||
};
|
||||
})();
|
||||
|
||||
@ -22,7 +22,6 @@
|
||||
position: fixed;
|
||||
z-index: 3000;
|
||||
inset: 0;
|
||||
--gallery-edge-gap: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.82);
|
||||
background-image:
|
||||
radial-gradient(circle at 50% 15%, rgba(42, 42, 42, 0.52), rgba(8, 8, 8, 0.95) 56%, #000 100%);
|
||||
@ -34,7 +33,7 @@
|
||||
|
||||
#gallery-stage {
|
||||
position: absolute;
|
||||
inset: var(--gallery-edge-gap);
|
||||
inset: 82px 22px 96px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@ -43,8 +42,8 @@
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: calc(100vw - (var(--gallery-edge-gap) * 2));
|
||||
max-height: calc(100vh - (var(--gallery-edge-gap) * 2));
|
||||
max-width: min(96vw, calc(100vw - 80px));
|
||||
max-height: calc(100vh - 190px);
|
||||
object-fit: contain;
|
||||
margin: auto;
|
||||
display: block;
|
||||
@ -259,7 +258,7 @@
|
||||
#gallery-filename,
|
||||
#gallery-position {
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.72rem;
|
||||
@ -286,10 +285,10 @@
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.56) 0%,
|
||||
rgba(255, 255, 255, 0.56) var(--filename-progress),
|
||||
rgba(0, 0, 0, 0.44) var(--filename-progress),
|
||||
rgba(0, 0, 0, 0.44) 100%
|
||||
rgba(255, 255, 255, 0.95) 0%,
|
||||
rgba(255, 255, 255, 0.95) var(--filename-progress),
|
||||
rgba(0, 0, 0, 0.62) var(--filename-progress),
|
||||
rgba(0, 0, 0, 0.62) 100%
|
||||
);
|
||||
z-index: 0;
|
||||
}
|
||||
@ -311,7 +310,7 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: inherit;
|
||||
padding: 0.45rem 0.72rem;
|
||||
box-sizing: border-box;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
@ -366,17 +365,13 @@
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
#gallery-modal {
|
||||
--gallery-edge-gap: 8px;
|
||||
}
|
||||
|
||||
#gallery-stage {
|
||||
inset: var(--gallery-edge-gap);
|
||||
inset: 74px 10px 110px;
|
||||
}
|
||||
|
||||
#gallery-modal-content {
|
||||
max-width: calc(100vw - (var(--gallery-edge-gap) * 2));
|
||||
max-height: calc(100vh - (var(--gallery-edge-gap) * 2));
|
||||
max-width: calc(100vw - 20px);
|
||||
max-height: calc(100vh - 210px);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@ -458,17 +453,13 @@
|
||||
}
|
||||
|
||||
@media (max-height: 520px) and (orientation: landscape) {
|
||||
#gallery-modal {
|
||||
--gallery-edge-gap: 6px;
|
||||
}
|
||||
|
||||
#gallery-stage {
|
||||
inset: var(--gallery-edge-gap);
|
||||
inset: 44px 10px 60px;
|
||||
}
|
||||
|
||||
#gallery-modal-content {
|
||||
max-width: calc(100vw - (var(--gallery-edge-gap) * 2));
|
||||
max-height: calc(100vh - (var(--gallery-edge-gap) * 2));
|
||||
max-width: calc(100vw - 20px);
|
||||
max-height: calc(100vh - 112px);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
@ -29,7 +29,6 @@ if (typeof currentGalleryIndex === "undefined") {
|
||||
}
|
||||
|
||||
let currentExifRequestId = 0;
|
||||
const previewRefreshInFlight = new Set();
|
||||
|
||||
function encodeGalleryPath(relUrl) {
|
||||
if (!relUrl) {
|
||||
@ -41,73 +40,6 @@ function encodeGalleryPath(relUrl) {
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function findBrowserThumbnailByRelUrl(relUrl) {
|
||||
if (!relUrl) {
|
||||
return null;
|
||||
}
|
||||
const links = document.querySelectorAll('.image-link[data-url]');
|
||||
for (const link of links) {
|
||||
if ((link.dataset.url || '') === relUrl) {
|
||||
return link.querySelector('img.thumbnail');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFallbackThumbnail(imgEl) {
|
||||
if (!imgEl) {
|
||||
return false;
|
||||
}
|
||||
const src = imgEl.getAttribute('src') || '';
|
||||
return imgEl.classList.contains('thumbnail-fallback') || src.startsWith('data:image/');
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function refreshBrowserThumbnailIfMissing(relUrl) {
|
||||
if (!relUrl || previewRefreshInFlight.has(relUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const thumbImg = findBrowserThumbnailByRelUrl(relUrl);
|
||||
if (!thumbImg || !isFallbackThumbnail(thumbImg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseThumbUrl = thumbImg.dataset.thumbUrl;
|
||||
if (!baseThumbUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewRefreshInFlight.add(relUrl);
|
||||
const separator = baseThumbUrl.includes('?') ? '&' : '?';
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const refreshUrl = `${baseThumbUrl}${separator}_=${Date.now()}-${attempt}`;
|
||||
const response = await fetch(refreshUrl, { method: 'GET', cache: 'no-store' }).catch(() => null);
|
||||
if (response && response.ok && response.status !== 204) {
|
||||
thumbImg.classList.remove('thumbnail-fallback');
|
||||
if (typeof handleThumbnailLoad === 'function') {
|
||||
thumbImg.onload = () => handleThumbnailLoad(thumbImg);
|
||||
}
|
||||
if (typeof handleThumbnailError === 'function') {
|
||||
thumbImg.onerror = () => handleThumbnailError(thumbImg);
|
||||
}
|
||||
thumbImg.src = refreshUrl;
|
||||
return;
|
||||
}
|
||||
if (attempt === 0) {
|
||||
await delay(250);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
previewRefreshInFlight.delete(relUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
@ -344,11 +276,6 @@ function showGalleryNav() {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function hideGalleryNav() {
|
||||
clearTimeout(navHideTimer);
|
||||
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.add('nav-hidden'));
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
@ -448,7 +375,6 @@ function showGalleryImage(relUrl) {
|
||||
clearTimeout(loaderTimeout);
|
||||
loader.style.display = 'none';
|
||||
updateFilenameDisplay(relUrl);
|
||||
refreshBrowserThumbnailIfMissing(relUrl);
|
||||
|
||||
// Select all current images in the gallery stage
|
||||
const existingImages = stage.querySelectorAll('img');
|
||||
@ -523,12 +449,8 @@ function showGalleryImage(relUrl) {
|
||||
|
||||
function preloadNextImage() {
|
||||
if (currentGalleryIndex < currentGalleryImages.length - 1) {
|
||||
const nextRelUrl = currentGalleryImages[currentGalleryIndex + 1];
|
||||
const nextImage = new Image();
|
||||
nextImage.onload = function () {
|
||||
refreshBrowserThumbnailIfMissing(nextRelUrl);
|
||||
};
|
||||
nextImage.src = '/media/' + nextRelUrl;
|
||||
nextImage.src = '/media/' + currentGalleryImages[currentGalleryIndex + 1];
|
||||
}
|
||||
}
|
||||
|
||||
@ -781,6 +703,7 @@ function handlePinchStart(e) {
|
||||
pinchStartMidY = (e.touches[0].pageY + e.touches[1].pageY) / 2;
|
||||
pinchStartTranslateX = currentTranslateX;
|
||||
pinchStartTranslateY = currentTranslateY;
|
||||
showGalleryNav();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -813,11 +736,7 @@ function handlePinchMove(e) {
|
||||
currentTranslateX = pinchStartTranslateX + (midX - pinchStartMidX);
|
||||
currentTranslateY = pinchStartTranslateY + (midY - pinchStartMidY);
|
||||
applyImageTransform(e.currentTarget);
|
||||
if (currentScale > 1.01) {
|
||||
showGalleryNav();
|
||||
} else {
|
||||
hideGalleryNav();
|
||||
}
|
||||
showGalleryNav();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -857,9 +776,6 @@ function handlePinchEnd(e) {
|
||||
currentScale = 1;
|
||||
currentTranslateX = 0;
|
||||
currentTranslateY = 0;
|
||||
applyImageTransform(image);
|
||||
hideGalleryNav();
|
||||
return;
|
||||
}
|
||||
applyImageTransform(image);
|
||||
showGalleryNav();
|
||||
|
||||
@ -181,8 +181,6 @@ async function loadMessages() {
|
||||
|
||||
function renderMessages() {
|
||||
const container = document.getElementById('messages-container');
|
||||
const esc = (value) => (window.escapeHtml ? window.escapeHtml(value) : escapeHtml(value));
|
||||
const sanitize = (html) => (window.sanitizeHtml ? window.sanitizeHtml(html) : String(html ?? ''));
|
||||
|
||||
if (messagesData.length === 0) {
|
||||
container.innerHTML = '<div class="no-messages">Keine Nachrichten vorhanden</div>';
|
||||
@ -191,14 +189,12 @@ function renderMessages() {
|
||||
|
||||
let html = '';
|
||||
messagesData.forEach(message => {
|
||||
const messageId = Number(message.id);
|
||||
const safeMessageId = Number.isFinite(messageId) ? messageId : 0;
|
||||
const datetime = formatDateTime(message.datetime);
|
||||
|
||||
// Convert folder: protocol to clickable buttons before markdown parsing
|
||||
let processedContent = message.content || '';
|
||||
processedContent = processedContent.replace(/\[([^\]]+)\]\(folder:([^)]+)\)/g,
|
||||
(match, name, path) => `<button class="folder-link-btn" data-folder-path="${esc(path)}"><i class="bi bi-folder2"></i> ${esc(name)}</button>`
|
||||
(match, name, path) => `<button class="folder-link-btn" data-folder-path="${escapeHtml(path)}"><i class="bi bi-folder2"></i> ${escapeHtml(name)}</button>`
|
||||
);
|
||||
|
||||
// Configure marked to allow all links
|
||||
@ -206,23 +202,23 @@ function renderMessages() {
|
||||
breaks: true,
|
||||
gfm: true
|
||||
});
|
||||
const contentHtml = sanitize(marked.parse(processedContent));
|
||||
const contentHtml = marked.parse(processedContent);
|
||||
|
||||
html += `
|
||||
<div class="message-card" data-message-id="${safeMessageId}">
|
||||
<div class="message-card" data-message-id="${message.id}">
|
||||
<div class="message-header">
|
||||
<h2 class="message-title">${esc(message.title)}</h2>
|
||||
<div class="message-datetime">${esc(datetime)}</div>
|
||||
<h2 class="message-title">${escapeHtml(message.title)}</h2>
|
||||
<div class="message-datetime">${datetime}</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
${contentHtml}
|
||||
</div>
|
||||
${admin_enabled ? `
|
||||
<div class="message-actions">
|
||||
<button class="btn-edit" onclick="editMessage(${safeMessageId})" title="Bearbeiten">
|
||||
<button class="btn-edit" onclick="editMessage(${message.id})" title="Bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn-delete" onclick="deleteMessage(${safeMessageId})" title="Löschen">
|
||||
<button class="btn-delete" onclick="deleteMessage(${message.id})" title="Löschen">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@ -251,7 +247,6 @@ function formatDateTime(datetimeStr) {
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (window.escapeHtml) return window.escapeHtml(text);
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
@ -259,7 +254,7 @@ function escapeHtml(text) {
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return String(text ?? '').replace(/[&<>"']/g, m => map[m]);
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
function openMessageModal(messageId = null) {
|
||||
|
||||
@ -2,21 +2,6 @@ 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) {
|
||||
@ -32,46 +17,36 @@ function initSearch() {
|
||||
if (results.length > 0) {
|
||||
results.forEach(file => {
|
||||
const card = document.createElement('div');
|
||||
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 filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.');
|
||||
const parentFolder = file.relative_path.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' || filename.toLowerCase().endsWith('.sng');
|
||||
const encodedRelPath = encodePath(relativePath);
|
||||
const isSng = (file.filetype || '').toLowerCase() === '.sng' || (file.filename || '').toLowerCase().endsWith('.sng');
|
||||
const encodedRelPath = encodeURI(file.relative_path);
|
||||
const fulltextType = file.fulltext_type || 'transcript';
|
||||
const fulltextUrl = file.fulltext_url || transcriptURL;
|
||||
const fulltextLabel = 'Treffer im Volltext';
|
||||
const fulltextAction = fulltextType === 'sng'
|
||||
? `<a href="#" class="open-sng-link" data-path="${esc(relativePath)}" title="Liedtext öffnen"><i class="bi bi-journal-text"></i></a>`
|
||||
: `<a href="#" class="show-transcript" data-url="${esc(fulltextUrl)}" data-audio-url="${esc(relativePath)}" highlight="${esc(file.query || '')}"><i class="bi bi-journal-text"></i></a>`;
|
||||
? `<a href="#" class="open-sng-link" data-path="${file.relative_path}" title="Liedtext öffnen"><i class="bi bi-journal-text"></i></a>`
|
||||
: `<a href="#" class="show-transcript" data-url="${fulltextUrl}" data-audio-url="${file.relative_path}" highlight="${file.query}"><i class="bi bi-journal-text"></i></a>`;
|
||||
|
||||
card.className = 'card';
|
||||
let fileAction = '';
|
||||
if (isSng) {
|
||||
fileAction = `<button class="btn btn-light open-sng-btn" data-path="${esc(relativePath)}" style="width:100%;"><i class="bi bi-music-note-list"></i> ${esc(filenameWithoutExtension)}</button>`;
|
||||
fileAction = `<button class="btn btn-light open-sng-btn" data-path="${file.relative_path}" style="width:100%;"><i class="bi bi-music-note-list"></i> ${filenameWithoutExtension}</button>`;
|
||||
} else if (isAudio) {
|
||||
fileAction = `<button class="btn btn-light play-audio-btn" data-path="${esc(relativePath)}" style="width:100%;"><i class="bi bi-volume-up"></i> ${esc(filenameWithoutExtension)}</button>`;
|
||||
fileAction = `<button class="btn btn-light play-audio-btn" onclick="player.loadTrack('${file.relative_path}')" style="width:100%;"><i class="bi bi-volume-up"></i> ${filenameWithoutExtension}</button>`;
|
||||
} else {
|
||||
fileAction = `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${esc(filename)}</a>`;
|
||||
fileAction = `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${file.filename}</a>`;
|
||||
}
|
||||
|
||||
const hitcount = Number(file.hitcount);
|
||||
const safeHitcount = Number.isFinite(hitcount) ? hitcount : 0;
|
||||
const transcriptHits = Number(file.transcript_hits);
|
||||
const safeTranscriptHits = Number.isFinite(transcriptHits) ? transcriptHits : 0;
|
||||
const perfDate = (file.performance_date !== undefined && file.performance_date !== null)
|
||||
? String(file.performance_date).trim()
|
||||
: '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-body">
|
||||
<p>${fileAction}</p>
|
||||
<p><button class="btn btn-light btn-sm folder-open-btn" data-folder="${esc(parentFolder)}" data-file="${esc(relativePath)}" style="width:100%;"><i class="bi bi-folder"></i> ${esc(parentFolder || 'Ordner')}</button></p>
|
||||
<p class="card-text">Anzahl Downloads: ${safeHitcount}</p>
|
||||
${ perfDate !== '' ? `<p class="card-text">Datum: ${esc(perfDate)}</p>` : ``}
|
||||
${ file.transcript_hits !== undefined ? `<p class="card-text">${fulltextLabel}: ${safeTranscriptHits} ${fulltextAction}</p>` : ``}
|
||||
<p><button class="btn btn-light btn-sm folder-open-btn" data-folder="${parentFolder}" data-file="${file.relative_path}" style="width:100%;"><i class="bi bi-folder"></i> ${parentFolder || 'Ordner'}</button></p>
|
||||
<p class="card-text">Anzahl Downloads: ${file.hitcount}</p>
|
||||
${ (file.performance_date !== undefined && file.performance_date !== null && String(file.performance_date).trim() !== '') ? `<p class="card-text">Datum: ${file.performance_date}</p>` : ``}
|
||||
${ file.transcript_hits !== undefined ? `<p class="card-text">${fulltextLabel}: ${file.transcript_hits} ${fulltextAction}</p>` : ``}
|
||||
</div>
|
||||
`;
|
||||
resultsDiv.appendChild(card);
|
||||
@ -84,7 +59,6 @@ function initSearch() {
|
||||
}
|
||||
attachEventListeners();
|
||||
attachSearchFolderButtons();
|
||||
attachSearchAudioButtons();
|
||||
attachSearchSngButtons();
|
||||
attachSearchFulltextButtons();
|
||||
} else {
|
||||
@ -197,19 +171,7 @@ function initSearch() {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.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(response => response.json())
|
||||
.then(data => {
|
||||
// Render the results
|
||||
renderResults(data);
|
||||
@ -227,10 +189,6 @@ function initSearch() {
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
const resultsDiv = document.getElementById('results');
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = `<div class="alert alert-danger">${esc(error.message || 'Search failed')}</div>`;
|
||||
}
|
||||
});
|
||||
// Always clear/hide spinner once the request settles
|
||||
if (typeof fetchPromise.finally === 'function') {
|
||||
@ -285,18 +243,6 @@ function attachSearchFolderButtons() {
|
||||
});
|
||||
}
|
||||
|
||||
function attachSearchAudioButtons() {
|
||||
document.querySelectorAll('.play-audio-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const path = btn.dataset.path;
|
||||
if (typeof player !== 'undefined' && player && typeof player.loadTrack === 'function') {
|
||||
player.loadTrack(path);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function attachSearchSngButtons() {
|
||||
document.querySelectorAll('.open-sng-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
@ -326,10 +272,7 @@ function openFolderAndHighlight(folderPath, filePath) {
|
||||
// Switch back to main view before loading folder
|
||||
viewMain();
|
||||
loadDirectory(targetFolder).then(() => {
|
||||
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}"]`);
|
||||
const target = document.querySelector(`.play-file[data-url=\"${filePath}\"]`);
|
||||
if (target) {
|
||||
target.classList.add('search-highlight');
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
@ -61,7 +61,6 @@
|
||||
<button type="button" id="backBtn" class="btn-close search-close-btn" aria-label="beenden"></button>
|
||||
<div class="container">
|
||||
<form id="searchForm" method="post" class="mb-4">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token }}">
|
||||
<!-- Suchwörter -->
|
||||
<div class="mb-3 search-query-wrap">
|
||||
<label for="query" class="h5 form-label">Suchwörter:</label>
|
||||
|
||||
@ -20,10 +20,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='audioplayer.css') }}">
|
||||
<script src="{{ url_for('static', filename='functions.js') }}"></script>
|
||||
<script>
|
||||
const admin_enabled = {{ admin_enabled | default(false) | tojson | safe }};
|
||||
window.CSRF_TOKEN = {{ csrf_token | tojson | safe }};
|
||||
</script>
|
||||
<script>const admin_enabled = {{ admin_enabled | default(false) | tojson | safe }};</script>
|
||||
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
|
||||
@ -28,7 +28,6 @@
|
||||
<button class="btn btn-secondary btn-sm" onclick="toClipboard('{{ secret_url[secret] }}')">Link kopieren</button>
|
||||
<br>
|
||||
<form method="post" action="{{ url_for('remove_secret') }}" class="mt-3">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="secret" value="{{ secret }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Link entfernen</button>
|
||||
</form>
|
||||
@ -64,7 +63,6 @@
|
||||
<button class="btn btn-secondary btn-sm" onclick="toClipboard('{{ token_url[token] }}')">Link kopieren</button>
|
||||
<br>
|
||||
<form method="post" action="{{ url_for('remove_token') }}" class="mt-3">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="token" value="{{ token }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Link entfernen</button>
|
||||
</form>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user