Compare commits

..

No commits in common. "7f95545e96f28915c47f987bc2ba9f98cac20022" and "b38d2fb49baca7590564742af596be1b2df26c2a" have entirely different histories.

13 changed files with 437 additions and 767 deletions

2
.gitignore vendored
View File

@ -10,7 +10,7 @@
/search.db /search.db
/access_log.db /access_log.db
/access_log.db.bak /access_log.db.bak
/folder_secret_config.json /folder_permission_config.json
/folder_mount_config.json /folder_mount_config.json
/.env /.env
/app_config.json /app_config.json

View File

@ -6,7 +6,6 @@ from auth import require_secret
from collections import defaultdict from collections import defaultdict
import json import json
import os import os
import auth
file_access_temp = [] file_access_temp = []
@ -190,11 +189,11 @@ def songs_dashboard():
performance_data = [(count, titel) for titel, count in performance_counts.items()] performance_data = [(count, titel) for titel, count in performance_counts.items()]
performance_data.sort(reverse=True, key=lambda x: x[0]) performance_data.sort(reverse=True, key=lambda x: x[0])
return render_template('songs_dashboard.html', timeframe=timeframe_param, performance_data=performance_data, site=site, category=category, admin_enabled=auth.is_admin()) return render_template('songs_dashboard.html', timeframe=timeframe_param, performance_data=performance_data, site=site, category=category)
@require_secret @require_secret
def connections(): def connections():
return render_template('connections.html', admin_enabled=auth.is_admin()) return render_template('connections.html')
@require_secret @require_secret
def dashboard(): def dashboard():
@ -477,8 +476,7 @@ def dashboard():
unique_files=unique_files, unique_files=unique_files,
unique_user=unique_user, unique_user=unique_user,
cached_percentage=cached_percentage, cached_percentage=cached_percentage,
timeframe_data=timeframe_data, timeframe_data=timeframe_data
admin_enabled=auth.is_admin()
) )
def export_to_excel(): def export_to_excel():

21
app.py
View File

@ -9,6 +9,7 @@ import mimetypes
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
import diskcache import diskcache
import threading import threading
import json
import time import time
from flask_socketio import SocketIO, emit from flask_socketio import SocketIO, emit
import geoip2.database import geoip2.database
@ -21,9 +22,12 @@ import base64
import search import search
import auth import auth
import analytics as a import analytics as a
import folder_secret_config_editor as fsce
app_config = auth.return_app_config() with open("app_config.json", 'r') as file:
app_config = json.load(file)
with open('folder_permission_config.json') as file:
folder_config = json.load(file)
cache_audio = diskcache.Cache('./filecache_audio', size_limit= app_config['filecache_size_limit_audio'] * 1024**3) cache_audio = diskcache.Cache('./filecache_audio', size_limit= app_config['filecache_size_limit_audio'] * 1024**3)
cache_image = diskcache.Cache('./filecache_image', size_limit= app_config['filecache_size_limit_image'] * 1024**3) cache_image = diskcache.Cache('./filecache_image', size_limit= app_config['filecache_size_limit_image'] * 1024**3)
@ -49,9 +53,6 @@ app.add_url_rule('/searchcommand', view_func=search.searchcommand, methods=['POS
app.add_url_rule('/songs_dashboard', view_func=a.songs_dashboard) app.add_url_rule('/songs_dashboard', view_func=a.songs_dashboard)
app.add_url_rule('/admin/folder_secret_config_editor', view_func=auth.require_admin(fsce.folder_secret_config_editor), methods=['GET', 'POST'])
app.add_url_rule('/admin/folder_secret_config_editor/data', view_func=auth.require_admin(auth.load_folder_config))
app.add_url_rule('/admin/folder_secret_config_editor/action', view_func=auth.require_admin(fsce.folder_secret_config_action), methods=['POST'])
# Grab the HOST_RULE environment variable # Grab the HOST_RULE environment variable
host_rule = os.getenv("HOST_RULE", "") host_rule = os.getenv("HOST_RULE", "")
@ -99,7 +100,6 @@ def list_directory_contents(directory, subpath):
""" """
directories = [] directories = []
files = [] files = []
folder_config = auth.return_folder_config()
transcription_dir = os.path.join(directory, "Transkription") transcription_dir = os.path.join(directory, "Transkription")
transcription_exists = os.path.isdir(transcription_dir) transcription_exists = os.path.isdir(transcription_dir)
@ -434,10 +434,9 @@ def get_transcript(subpath):
def create_share(subpath): def create_share(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 'admin ' not in session and not session.get('admin'):
return "Unauthorized", 403 return "Unauthorized", 403
folder_config = auth.return_folder_config()
paths = {} paths = {}
for item in folder_config: for item in folder_config:
for folder in item['folders']: for folder in item['folders']:
@ -547,14 +546,16 @@ def handle_request_initial_data():
@app.route('/<path:path>') @app.route('/<path:path>')
@auth.require_secret @auth.require_secret
def index(path): def index(path):
app_config = auth.return_app_config()
title_short = app_config.get('TITLE_SHORT', 'Default Title') title_short = app_config.get('TITLE_SHORT', 'Default Title')
title_long = app_config.get('TITLE_LONG' , 'Default Title') title_long = app_config.get('TITLE_LONG' , 'Default Title')
admin_enabled = False
if 'admin' in session:
admin_enabled = session['admin']
return render_template("app.html", return render_template("app.html",
title_short=title_short, title_short=title_short,
title_long=title_long, title_long=title_long,
admin_enabled=auth.is_admin() admin_enabled=admin_enabled,
) )
if __name__ == '__main__': if __name__ == '__main__':

58
auth.py
View File

@ -12,42 +12,14 @@ import zlib
import hmac import hmac
import hashlib import hashlib
FOLDER_CONFIG_FILENAME = 'folder_secret_config.json'
APP_CONFIG_FILENAME = 'app_config.json'
# initial read of the config files
with open(APP_CONFIG_FILENAME, 'r') as file: with open("app_config.json", 'r') as file:
app_config = json.load(file) app_config = json.load(file)
with open(FOLDER_CONFIG_FILENAME) as file: with open('folder_permission_config.json') as file:
folder_config = json.load(file) folder_config = json.load(file)
# functions to be used by other modules
def load_folder_config():
global folder_config
with open(FOLDER_CONFIG_FILENAME) as file:
folder_config = json.load(file)
return folder_config
def load_app_config():
global app_config
with open(APP_CONFIG_FILENAME, 'r') as file:
app_config = json.load(file)
return app_config
def return_folder_config():
return folder_config
def return_app_config():
return app_config
def is_admin():
"""
Check if the user is an admin based on the session.
"""
return session.get('admin', False)
def require_secret(f): def require_secret(f):
@wraps(f) @wraps(f)
@ -132,10 +104,8 @@ def require_secret(f):
if args_admin and config_admin: if args_admin and config_admin:
if args_admin == config_admin: if args_admin == config_admin:
print(f"Admin access granted with key: {args_admin}")
session['admin'] = True session['admin'] = True
else: else:
print(f"Admin access denied with key: {args_admin}")
session['admin'] = False session['admin'] = False
# 6) If we have folders, proceed; otherwise show index # 6) If we have folders, proceed; otherwise show index
@ -159,28 +129,9 @@ def require_secret(f):
header_text_color=header_text_color, header_text_color=header_text_color,
main_text_color=main_text_color, main_text_color=main_text_color,
background_color=background_color, background_color=background_color,
admin_enabled=is_admin()
) )
return decorated_function return decorated_function
def require_admin(f):
@wraps(f)
@require_secret
def decorated_function(*args, **kwargs):
if is_admin():
return f(*args, **kwargs)
else:
return "You don't have admin permission", 403
return decorated_function
@require_admin
def save_folder_config(data):
global folder_config
folder_config = data
with open(FOLDER_CONFIG_FILENAME, 'w') as file:
json.dump(folder_config, file, indent=4)
return folder_config
@require_secret @require_secret
def mylinks(): def mylinks():
scheme = request.scheme # current scheme (http or https) scheme = request.scheme # current scheme (http or https)
@ -246,8 +197,7 @@ def mylinks():
token_qr_codes=token_qr_codes, token_qr_codes=token_qr_codes,
token_folders=token_folders, token_folders=token_folders,
token_url=token_url, token_url=token_url,
token_valid_to=token_valid_to, token_valid_to=token_valid_to
admin_enabled=is_admin()
) )

View File

@ -1,42 +0,0 @@
from flask import Flask, request, jsonify, render_template
import json
import os
from datetime import datetime
import secrets
import string
import auth
# Secret alphabet
ALPHABET = string.ascii_letters + string.digits
@auth.require_admin
def folder_secret_config_editor():
return render_template('folder_secret_config_editor.html', alphabet=ALPHABET, admin_enabled=auth.is_admin())
@auth.require_admin
def folder_secret_config_action():
p = request.get_json()
data = auth.return_folder_config()
action = p.get('action')
if action == 'delete':
data = [r for r in data if r['secret'] != p['secret']]
elif action == 'update':
old = p['oldSecret']; new = p['newSecret']
for i, r in enumerate(data):
if r['secret'] == old:
r['secret'] = new
r['validity'] = datetime.strptime(p['validity'], '%Y-%m-%d').strftime('%d.%m.%Y')
r['folders'] = p['folders']
break
else:
# append if not found
data.append({
'secret': new,
'validity': datetime.strptime(p['validity'], '%Y-%m-%d').strftime('%d.%m.%Y'),
'folders': p['folders']
})
auth.save_folder_config(data)
return jsonify(success=True)

View File

@ -43,17 +43,6 @@
<h1>{{ title_long }}</h1> <h1>{{ title_long }}</h1>
</header> </header>
<div class="wrapper"> <div class="wrapper">
{% if admin_enabled %}
<div style="color: #ccc; border: 1px solid #ccc; padding: 10px; background-color: #979797;">
<a href="{{ url_for('mylinks') }}" class="btn" style="color: #ccc; text-decoration: none;">Meine Links</a>
<span> | </span>
<a href="{{ url_for('connections') }}" class="btn" style="color: #ccc; text-decoration: none;">Verbindungen</a>
<span> | </span>
<a href="{{ url_for('dashboard') }}" class="btn" style="color: #ccc; text-decoration: none;">Auswertung</a>
<span> | </span>
<a href="{{ url_for('folder_secret_config_editor') }}" class="btn" style="color: #ccc; text-decoration: none;" id="edit-folder-config" >Ordnerkonfiguration</a>
</div>
{% endif %}
<div class="container"> <div class="container">
<div id="breadcrumbs" class="breadcrumb"></div> <div id="breadcrumbs" class="breadcrumb"></div>
<div id="content"></div> <div id="content"></div>

View File

@ -1,62 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Meine Links{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<style>
/* common styles */
html, body {
height: 100%;
margin: 0;
padding: 0;
}
.container-fluid {
height: 100%;
display: flex;
flex-direction: column;
}
.qr-code {
max-width: 300px;
display: block;
margin: auto;
}
.card {
margin-bottom: 1.5rem;
}
.locked { background-color:#eee; }
.unlocked { background-color: #fff3cd; }
</style>
{% block head_extra %}{% endblock %}
</head>
<body>
<!-- Navigation Bar -->
<div style="background-color: #979797; padding: 5px; display: flex; gap: 5px; flex-wrap: wrap;">
<a href="{{ url_for('index') }}" class="btn btn-secondary btn-sm">App</a>
<a href="{{ url_for('mylinks') }}" class="btn btn-secondary btn-sm">Meine Links</a>
<a href="{{ url_for('connections') }}" class="btn btn-secondary btn-sm">Verbindungen</a>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary btn-sm">Auswertung-Downloads</a>
<a href="{{ url_for('songs_dashboard') }}" class="btn btn-secondary btn-sm">Auswertung-Wiederholungen</a>
{% if admin_enabled %}
<a href="{{ url_for('folder_secret_config_editor') }}" class="btn btn-secondary btn-sm" id="edit-folder-config">Ordnerkonfiguration</a>
{% endif %}
{% block nav_extra %}{% endblock %}
</div>
<div class="container">
<h2>{% block nav_brand %}Übersicht deiner gültigen Links{% endblock %}</h2>
</div>
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='functions.js') }}"></script>
</body>
</html>

View File

@ -1,38 +1,83 @@
{# templates/connections.html #} <!DOCTYPE html>
{% extends 'base.html' %} <html lang="en">
<head>
{% block title %}Recent Connections{% endblock %} <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{% block nav_brand %}Downloads der letzten 10 Minuten{% endblock %} <title>Recent Connections</title>
<link
{% block nav_extra %} href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
<span class="ms-3"> rel="stylesheet"
Anzahl Verbindungen: <strong id="totalConnections">0</strong> />
</span>
{% endblock %}
{% block head_extra %}
<style> <style>
/* page-specific styles */ /* Basic layout */
.table-container { flex: 1; overflow-y: auto; } html,
body {
height: 100%;
margin: 0;
padding: 0;
}
.container-fluid {
height: 100%;
display: flex;
flex-direction: column;
}
.table-container {
flex: 1;
overflow-y: auto;
}
/* Animate the table body moving down via transform */
#connectionsTableBody { #connectionsTableBody {
transition: transform 0.5s ease-out; transition: transform 0.5s ease-out;
transform: translateY(0); transform: translateY(0);
} }
/* Advanced animation for new rows */
@keyframes slideIn { @keyframes slideIn {
0% { opacity: 0; transform: scale(0.8); } 0% {
100% { opacity: 1; transform: scale(1); } opacity: 0;
transform: scale(0.8);
} }
.slide-in { animation: slideIn 0.5s ease-out; } 100% {
opacity: 1;
transform: scale(1);
}
}
.slide-in {
animation: slideIn 0.5s ease-out;
}
/* Animation for disappearing rows */
@keyframes slideOut { @keyframes slideOut {
0% { opacity: 1; transform: scale(1); } 0% {
100% { opacity: 0; transform: scale(0.1); } opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.1);
}
}
.slide-out {
animation: slideOut 0.5s ease-in forwards;
} }
.slide-out { animation: slideOut 0.5s ease-in forwards; }
</style> </style>
{% endblock %} </head>
<body>
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">Downloads der letzten 10 Minuten</a>
<div class="navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a href="{{ url_for('index') }}" class="nav-link">App</a></li>
<li class="nav-item"><a href="{{ url_for('mylinks') }}" class="nav-link">Meine Links</a></li>
<li class="nav-item"><a href="{{ url_for('connections') }}" class="nav-link">Verbindungen</a></li>
<li class="nav-item"><a href="{{ url_for('dashboard') }}" class="nav-link">Auswertung-Downloads</a></li>
<li class="nav-item"><a href="{{ url_for('songs_dashboard') }}" class="nav-link">Auswertung-Wiederholungen</a></li>
</ul>
</div>
<span>Anzahl Verbindungen: <strong id="totalConnections">0</strong></span>
</div>
</nav>
{% block content %}
<div class="container-fluid"> <div class="container-fluid">
<div class="table-responsive table-container"> <div class="table-responsive table-container">
<table class="table table-hover"> <table class="table table-hover">
@ -48,32 +93,39 @@
</tr> </tr>
</thead> </thead>
<tbody id="connectionsTableBody"> <tbody id="connectionsTableBody">
{# dynamically populated via Socket.IO #} <!-- Rows are dynamically inserted here -->
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{% endblock %}
{% block scripts %} <!-- Socket.IO client library -->
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script> <script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
<script> <script>
const socket = io(); const socket = io();
socket.on("connect", () => socket.emit("request_initial_data"));
// Request initial data once connected.
socket.on("connect", () => {
socket.emit("request_initial_data");
});
// compute & write stats into the header
function updateStats(data) { function updateStats(data) {
document.getElementById("totalConnections").textContent = data.length; const total = data.length;
document.getElementById("totalConnections").textContent = total;
} }
function createRow(record, animate=true) { // Helper: Create a table row.
const tr = document.createElement("tr"); // When applyAnimation is true, the row uses the slide-in animation.
tr.dataset.timestamp = record.timestamp; function createRow(record, applyAnimation = true) {
if (animate) { const row = document.createElement("tr");
tr.classList.add("slide-in"); row.setAttribute("data-timestamp", record.timestamp);
tr.addEventListener("animationend", () => if (applyAnimation) {
tr.classList.remove("slide-in"), { once: true }); row.classList.add("slide-in");
row.addEventListener("animationend", () =>
row.classList.remove("slide-in"), { once: true });
} }
tr.innerHTML = ` row.innerHTML = `
<td>${record.timestamp}</td> <td>${record.timestamp}</td>
<td>${record.location}</td> <td>${record.location}</td>
<td>${record.user_agent}</td> <td>${record.user_agent}</td>
@ -82,47 +134,71 @@
<td>${record.mime_typ}</td> <td>${record.mime_typ}</td>
<td>${record.cached}</td> <td>${record.cached}</td>
`; `;
return tr; return row;
} }
// Update the table body with the new data.
// New rows animate in; existing rows simply update.
function updateTable(data) { function updateTable(data) {
const tbody = document.getElementById("connectionsTableBody"); const tbody = document.getElementById("connectionsTableBody");
const existing = {}; // Map current rows by their timestamp.
Array.from(tbody.children).forEach(r => existing[r.dataset.timestamp] = r); const currentRows = {};
Array.from(tbody.children).forEach(row => {
const frag = document.createDocumentFragment(); currentRows[row.getAttribute("data-timestamp")] = row;
data.forEach(rec => { });
let row = existing[rec.timestamp]; const fragment = document.createDocumentFragment();
if (row) { data.forEach(record => {
row.innerHTML = createRow(rec, false).innerHTML; let row;
if (currentRows[record.timestamp]) {
// Existing row: update content without new animation.
row = currentRows[record.timestamp];
row.innerHTML = `
<td>${record.timestamp}</td>
<td>${record.location}</td>
<td>${record.user_agent}</td>
<td>${record.full_path}</td>
<td>${record.filesize}</td>
<td>${record.mime_typ}</td>
<td>${record.cached}</td>
`;
row.classList.remove("slide-out"); row.classList.remove("slide-out");
} else { } else {
row = createRow(rec, true); // New row: create with slide-in animation.
row = createRow(record, true);
} }
frag.appendChild(row); fragment.appendChild(row);
}); });
tbody.innerHTML = ""; tbody.innerHTML = "";
tbody.appendChild(frag); tbody.appendChild(fragment);
} }
// Animate the table body moving down by the height of a new row before updating.
function animateTableWithNewRow(data) { function animateTableWithNewRow(data) {
const tbody = document.getElementById("connectionsTableBody"); const tbody = document.getElementById("connectionsTableBody");
const currentTs = new Set([...tbody.children].map(r => r.dataset.timestamp)); // Identify any new records (by comparing timestamps).
const newRecs = data.filter(r => !currentTs.has(r.timestamp)); const currentTimestamps = new Set(
Array.from(tbody.children).map(row => row.getAttribute("data-timestamp"))
);
const newRecords = data.filter(record => !currentTimestamps.has(record.timestamp));
if (newRecs.length) { if (newRecords.length > 0) {
const temp = createRow(newRecs[0], false); // Create a temporary row to measure its height.
temp.style.visibility = "hidden"; const tempRow = createRow(newRecords[0], false);
tbody.appendChild(temp); tempRow.style.visibility = "hidden";
const h = temp.getBoundingClientRect().height; tbody.appendChild(tempRow);
temp.remove(); const newRowHeight = tempRow.getBoundingClientRect().height;
tempRow.remove();
tbody.style.transform = `translateY(${h}px)`; // Animate the tbody moving down by the measured height.
tbody.style.transform = `translateY(${newRowHeight}px)`;
// After the transition, update the table and reset the transform.
setTimeout(() => { setTimeout(() => {
updateTable(data); updateTable(data);
// Remove the transform instantly.
tbody.style.transition = "none"; tbody.style.transition = "none";
tbody.style.transform = "translateY(0)"; tbody.style.transform = "translateY(0)";
// Force reflow to apply the style immediately.
void tbody.offsetWidth; void tbody.offsetWidth;
tbody.style.transition = "transform 0.5s ease-out"; tbody.style.transition = "transform 0.5s ease-out";
}, 500); }, 500);
@ -131,9 +207,12 @@
} }
} }
socket.on("recent_connections", data => { // Listen for incoming connection data from the server.
socket.on("recent_connections", function(data) {
updateStats(data); updateStats(data);
animateTableWithNewRow(data); animateTableWithNewRow(data);
}); });
</script> </script>
{% endblock %} <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -1,14 +1,42 @@
{# templates/dashboard.html #} <!DOCTYPE html>
{% extends 'base.html' %} <html lang="en">
<head>
{# page title #} <meta charset="UTF-8">
{% block title %}Dashboard{% endblock %} <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard - Verbindungsanalyse</title>
{# override navbar text: #} <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">
{% block nav_brand %}Auswertung-Downloads{% endblock %} <style>
html, body {
{# page content #} height: 100%;
{% block content %} margin: 0;
padding: 0;
}
.container-fluid {
height: 100%;
display: flex;
flex-direction: column;
}
.card {
margin-bottom: 1.5rem;
}
</style>
</head>
<body>
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">Dashboard - Verbindungsanalyse</a>
<div class="navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a href="{{ url_for('index') }}" class="nav-link">App</a></li>
<li class="nav-item"><a href="{{ url_for('mylinks') }}" class="nav-link">Meine Links</a></li>
<li class="nav-item"><a href="{{ url_for('connections') }}" class="nav-link">Verbindungen</a></li>
<li class="nav-item"><a href="{{ url_for('dashboard') }}" class="nav-link">Auswertung-Downloads</a></li>
<li class="nav-item"><a href="{{ url_for('songs_dashboard') }}" class="nav-link">Auswertung-Wiederholungen</a></li>
</ul>
</div>
</div>
</nav>
<!-- Main Container --> <!-- Main Container -->
<div class="container-fluid px-4"> <div class="container-fluid px-4">
@ -145,7 +173,7 @@
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Anzahl Geräte</h5> <h5 class="card-title">Anzahl Nutzer</h5>
<canvas id="distinctDeviceChart"></canvas> <canvas id="distinctDeviceChart"></canvas>
</div> </div>
</div> </div>
@ -243,9 +271,9 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
{% block scripts %} <!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script> <script>
// Data passed from the backend as JSON // Data passed from the backend as JSON
@ -361,4 +389,5 @@
options: { responsive: true } options: { responsive: true }
}); });
</script> </script>
{% endblock %} </body>
</html>

View File

@ -1,317 +0,0 @@
{# templates/mylinks.html #}
{% extends 'base.html' %}
{# page title #}
{% block title %}Ordnerkonfiguration{% endblock %}
{# override navbar text: #}
{% block nav_brand %}Ordnerkonfiguration{% endblock %}
{# page content #}
{% block content %}
<div class="container">
<div id="records"></div>
<button id="add-btn" class="btn btn-primary mt-3">Add New Record</button>
</div>
<!-- Confirmation Modal -->
<div class="modal fade" id="confirmDeleteModal" tabindex="-1" aria-labelledby="confirmDeleteLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmDeleteLabel">Confirm Deletion</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Do you really want to delete this record?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger confirm-delete">Delete</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const ALPHABET = {{ alphabet|tojson }};
let data = [];
let editing = new Set();
let pendingDelete = null;
// helper to format DD.MM.YYYY → YYYY-MM-DD
function formatISO(d) {
const [dd, mm, yyyy] = d.split('.');
return (dd && mm && yyyy) ? `${yyyy}-${mm}-${dd}` : '';
}
// load from server
async function loadData() {
try {
const res = await fetch('/admin/folder_secret_config_editor/data');
data = await res.json();
render();
} catch (err) {
console.error(err);
}
}
// send delete/update actions
async function sendAction(payload) {
await fetch('/admin/folder_secret_config_editor/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
await loadData();
}
// delete record after confirmation
function deleteRec(secret) {
sendAction({ action: 'delete', secret });
}
// build all cards
function render() {
const cont = document.getElementById('records');
cont.innerHTML = '';
data.forEach(rec => {
const key = rec.secret;
const isEdit = editing.has(key);
const expired = (formatISO(rec.validity) && new Date(formatISO(rec.validity)) < new Date());
const cls = isEdit ? 'unlocked' : 'locked';
// outer card
const wrapper = document.createElement('div');
wrapper.className = 'card mb-3';
wrapper.dataset.secret = key;
const body = document.createElement('div');
body.className = `card-body ${cls}`;
// header
const h5 = document.createElement('h5');
h5.innerHTML = `Link${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}`;
body.appendChild(h5);
// secret input
const secDiv = document.createElement('div');
secDiv.className = 'mb-2';
secDiv.innerHTML = `Secret: `;
const secInput = document.createElement('input');
secInput.className = 'form-control';
secInput.type = 'text';
secInput.value = rec.secret;
secInput.readOnly = !isEdit;
secInput.dataset.field = 'secret';
secDiv.appendChild(secInput);
body.appendChild(secDiv);
// validity input
const valDiv = document.createElement('div');
valDiv.className = 'mb-2';
valDiv.innerHTML = `Gültig bis: `;
const valInput = document.createElement('input');
valInput.className = 'form-control';
valInput.type = 'date';
valInput.value = formatISO(rec.validity);
valInput.readOnly = !isEdit;
valInput.dataset.field = 'validity';
valDiv.appendChild(valInput);
body.appendChild(valDiv);
// folders
const folderHeader = document.createElement('h6');
folderHeader.textContent = 'Ordner';
body.appendChild(folderHeader);
rec.folders.forEach((f, i) => {
const fwrap = document.createElement('div');
fwrap.style = 'border:1px solid #ccc; padding:10px; margin-bottom:10px; border-radius:5px;';
// name
fwrap.appendChild(document.createTextNode("Ordnername: "));
const nameGroup = document.createElement('div');
nameGroup.className = 'input-group mb-2';
const nameInput = document.createElement('input');
nameInput.className = 'form-control';
nameInput.type = 'text';
nameInput.value = f.foldername;
nameInput.readOnly = !isEdit;
nameInput.dataset.field = `foldername-${i}`;
nameGroup.appendChild(nameInput);
if (isEdit) {
const remBtn = document.createElement('button');
remBtn.className = 'btn btn-outline-danger';
remBtn.type = 'button';
remBtn.textContent = 'Remove';
remBtn.addEventListener('click', () => removeFolder(key, i));
nameGroup.appendChild(remBtn);
}
fwrap.appendChild(nameGroup);
// path
fwrap.appendChild(document.createTextNode("Ordnerpfad: "));
const pathInput = document.createElement('input');
pathInput.className = 'form-control mb-2';
pathInput.type = 'text';
pathInput.value = f.folderpath;
pathInput.readOnly = !isEdit;
pathInput.dataset.field = `folderpath-${i}`;
fwrap.appendChild(pathInput);
body.appendChild(fwrap);
});
if (isEdit) {
const addFld = document.createElement('button');
addFld.className = 'btn btn-sm btn-primary mb-2';
addFld.type = 'button';
addFld.textContent = 'Add Folder';
addFld.addEventListener('click', () => addFolder(key));
body.appendChild(addFld);
}
// actions row
const actions = document.createElement('div');
if (!isEdit) {
const openLink = document.createElement('a');
openLink.className = 'btn btn-secondary btn-sm me-2';
openLink.href = `/?secret=${rec.secret}`;
openLink.textContent = 'Link öffnen';
actions.appendChild(openLink);
}
const delBtn = document.createElement('button');
delBtn.className = 'btn btn-danger btn-sm me-2 delete-btn';
delBtn.type = 'button';
delBtn.textContent = 'Delete';
delBtn.dataset.secret = key;
actions.appendChild(delBtn);
const cloneBtn = document.createElement('button');
cloneBtn.className = 'btn btn-secondary btn-sm me-2';
cloneBtn.type = 'button';
cloneBtn.textContent = 'Clone';
cloneBtn.addEventListener('click', () => cloneRec(key));
actions.appendChild(cloneBtn);
if (isEdit) {
const saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-success btn-sm';
saveBtn.type = 'button';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', () => saveRec(key));
actions.appendChild(saveBtn);
} else {
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-warning btn-sm';
editBtn.type = 'button';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => editRec(key));
actions.appendChild(editBtn);
}
body.appendChild(actions);
wrapper.appendChild(body);
cont.appendChild(wrapper);
});
}
// generate a unique secret
function generateSecret(existing) {
let s;
do {
s = Array.from({length:32}, () => ALPHABET[Math.floor(Math.random()*ALPHABET.length)]).join('');
} while (existing.includes(s));
return s;
}
// CRUD helpers
function editRec(secret) {
editing.add(secret);
render();
}
function cloneRec(secret) {
const idx = data.findIndex(r => r.secret === secret);
const rec = JSON.parse(JSON.stringify(data[idx]));
const existing = data.map(r => r.secret);
rec.secret = generateSecret(existing);
data.splice(idx+1, 0, rec);
editing.add(rec.secret);
render();
}
function addFolder(secret) {
data.find(r => r.secret === secret).folders.push({foldername:'', folderpath:''});
render();
}
function removeFolder(secret, i) {
data.find(r => r.secret === secret).folders.splice(i,1);
render();
}
async function saveRec(secret) {
const card = document.querySelector(`[data-secret="${secret}"]`);
const newSecret = card.querySelector('input[data-field="secret"]').value.trim();
const validity = card.querySelector('input[data-field="validity"]').value;
const rec = data.find(r => r.secret === secret);
const folders = rec.folders.map((_, i) => ({
foldername: card.querySelector(`input[data-field="foldername-${i}"]`).value.trim(),
folderpath: card.querySelector(`input[data-field="folderpath-${i}"]`).value.trim()
}));
if (!newSecret || data.some(r => r.secret !== secret && r.secret === newSecret)) {
return alert('Secret must be unique and non-empty');
}
if (!validity) {
return alert('Validity required');
}
if (folders.some(f => !f.foldername || !f.folderpath)) {
return alert('Folder entries cannot be empty');
}
await sendAction({
action: 'update',
oldSecret: secret,
newSecret,
validity,
folders
});
editing.delete(secret);
await loadData();
}
// modal handling for delete
document.addEventListener('click', e => {
if (e.target.matches('.delete-btn')) {
pendingDelete = e.target.dataset.secret;
const modal = new bootstrap.Modal(document.getElementById('confirmDeleteModal'));
modal.show();
}
});
document.querySelector('.confirm-delete').addEventListener('click', () => {
if (pendingDelete) deleteRec(pendingDelete);
pendingDelete = null;
const modal = bootstrap.Modal.getInstance(document.getElementById('confirmDeleteModal'));
modal.hide();
});
// init
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('add-btn').addEventListener('click', () => {
const existing = data.map(r => r.secret);
const newSecret = generateSecret(existing);
data.push({ secret: newSecret, validity: new Date().toISOString().slice(0,10), folders: [] });
editing.add(newSecret);
render();
});
loadData();
});
</script>
{% endblock %}

View File

@ -1,37 +1,60 @@
{# templates/mylinks.html #} <!DOCTYPE html>
{% extends 'base.html' %} <html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Meine Links</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
}
.container-fluid {
height: 100%;
display: flex;
flex-direction: column;
}
.qr-code {
max-width: 300px;
display: block;
margin: auto;
}
</style>
</head>
<body>
<script src="{{ url_for('static', filename='functions.js') }}"></script>
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">Übersicht deiner gültigen Links</a>
<div class="navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a href="{{ url_for('index') }}" class="nav-link">App</a></li>
<li class="nav-item"><a href="{{ url_for('mylinks') }}" class="nav-link">Meine Links</a></li>
<li class="nav-item"><a href="{{ url_for('connections') }}" class="nav-link">Verbindungen</a></li>
<li class="nav-item"><a href="{{ url_for('dashboard') }}" class="nav-link">Auswertung-Downloads</a></li>
<li class="nav-item"><a href="{{ url_for('songs_dashboard') }}" class="nav-link">Auswertung-Wiederholungen</a></li>
</ul>
</div>
</div>
</nav>
{# page title #} <div class="container-fluid">
{% block title %}Meine Links{% endblock %}
{# override navbar text: #}
{#
{% block nav_brand %}
Übersicht deiner gültigen Links
{% endblock %}
#}
{# pagespecific content #}
{% block content %}
<div class="container-fluid">
<div class="row"> <div class="row">
{% if valid_secrets %} {% if valid_secrets %}
{% for secret in valid_secrets %} {% for secret in valid_secrets %}
<div class="col-md-4 mb-4"> <div class="col-md-4 mb-4">
<div class="card h-100 shadow-sm"> <div class="card h-100 shadow-sm">
<img src="data:image/png;base64,{{ secret_qr_codes[secret] }}" <img src="data:image/png;base64,{{ secret_qr_codes[secret] }}" class="card-img-top qr-code p-3" alt="QR Code for secret">
class="card-img-top qr-code p-3"
alt="QR Code for secret">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Geheimnis: {{ secret }}</h5> <h5 class="card-title">Geheimnis: {{ secret }}</h5>
<p class="card-text"> <p class="card-text">
<small class="text-muted">Gültig bis: {{ secret_valid_to[secret] }}</small> <small class="text-muted">Gültig bis: {{ secret_valid_to[secret] }}</small>
</p> </p>
<a href="{{ secret_url[secret] }}" class="btn btn-secondary btn-sm" style="text-decoration: none;">Link öffnen</a> <a href="{{ secret_url[secret] }}" class="btn btn-secondary btn-sm">Link öffnen</a>
<button class="btn btn-secondary btn-sm" <button class="btn btn-secondary btn-sm" onclick="toClipboard('{{ secret_url[secret] }}')">Link kopieren</button>
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="secret" value="{{ secret }}"> <input type="hidden" name="secret" value="{{ secret }}">
@ -54,24 +77,18 @@
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if valid_tokens %} {% if valid_tokens %}
{% for token in valid_tokens %} {% for token in valid_tokens %}
<div class="col-md-4 mb-4"> <div class="col-md-4 mb-4">
<div class="card h-100 shadow-sm"> <div class="card h-100 shadow-sm">
<img src="data:image/png;base64,{{ token_qr_codes[token] }}" <img src="data:image/png;base64,{{ token_qr_codes[token] }}" class="card-img-top qr-code p-3" alt="QR Code for token">
class="card-img-top qr-code p-3"
alt="QR Code for token">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Token-Link:</h5> <h5 class="card-title">Token-Link:</h5>
<p class="card-text"> <p class="card-text">
<small class="text-muted">Gültig bis: {{ token_valid_to[token] }}</small> <small class="text-muted">Gültig bis: {{ token_valid_to[token] }}</small>
</p> </p>
<a href="{{ token_url[token] }}" class="btn btn-secondary btn-sm">Link öffnen</a> <a href="{{ token_url[token] }}" class="btn btn-secondary btn-sm">Link öffnen</a>
<button class="btn btn-secondary btn-sm" <button class="btn btn-secondary btn-sm" onclick="toClipboard('{{ token_url[token] }}')">Link kopieren</button>
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="token" value="{{ token }}"> <input type="hidden" name="token" value="{{ token }}">
@ -94,12 +111,13 @@
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if not valid_secrets and not valid_tokens %} {% if not valid_secrets and not valid_tokens %}
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
Du hast aktuell keine gültigen Links. Du hast aktuell keine gültigen Links.
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endblock %} <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -1,14 +1,42 @@
{# templates/songs_dashboard.html #} <!DOCTYPE html>
{% extends 'base.html' %} <html lang="en">
<head>
{# page title #} <meta charset="UTF-8">
{% block title %}Edit Folder Config{% endblock %} <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard - Verbindungsanalyse</title>
{# override navbar text: #} <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">
{% block nav_brand %}Dashboard - Analyse Wiederholungen{% endblock %} <style>
html, body {
{# page content #} height: 100%;
{% block content %} margin: 0;
padding: 0;
}
.container-fluid {
height: 100%;
display: flex;
flex-direction: column;
}
.card {
margin-bottom: 1.5rem;
}
</style>
</head>
<body>
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">Dashboard - Analyse Wiederholungen</a>
<div class="navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a href="{{ url_for('index') }}" class="nav-link">App</a></li>
<li class="nav-item"><a href="{{ url_for('mylinks') }}" class="nav-link">Meine Links</a></li>
<li class="nav-item"><a href="{{ url_for('connections') }}" class="nav-link">Verbindungen</a></li>
<li class="nav-item"><a href="{{ url_for('dashboard') }}" class="nav-link">Auswertung-Downloads</a></li>
<li class="nav-item"><a href="{{ url_for('songs_dashboard') }}" class="nav-link">Auswertung-Wiederholungen</a></li>
</ul>
</div>
</div>
</nav>
<!-- Main Container --> <!-- Main Container -->
<div class="container-fluid px-4"> <div class="container-fluid px-4">
@ -152,4 +180,3 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html> </html>
{% endblock %}

View File

@ -33,8 +33,8 @@
<div class="card-body"> <div class="card-body">
<div class="card-text mt-2"> <div class="card-text mt-2">
<h2 class="card-title">Token-Link:</h2> <h2 class="card-title">Token-Link:</h2>
<a href="{{ token_url }}" class="copy-btn" style="text-decoration: none;" id="tokenLink">Link öffnen</a> <a href="{{ token_url }}" class="copy-btn" id="tokenLink">Direct Link</a>
<button class="copy-btn" onclick="toClipboard('{{ token_url }}')">Link kopieren</button> <button class="copy-btn" onclick="toClipboard('{{ token_url }}')">Copy Link</button>
</div> </div>
<div class="card-text mt-2"> <div class="card-text mt-2">
<h2 class="mt-3">Gültig bis:</h2> <h2 class="mt-3">Gültig bis:</h2>