Compare commits
10 Commits
b38d2fb49b
...
7f95545e96
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f95545e96 | |||
| 9cbb58417e | |||
| f7137e2d4c | |||
| fceeabe652 | |||
| 80e3b1abb5 | |||
| cb63447629 | |||
| 849cdf5756 | |||
| 8f6cf3d7b8 | |||
| d920dea10b | |||
| 384874d4f5 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -10,7 +10,7 @@
|
|||||||
/search.db
|
/search.db
|
||||||
/access_log.db
|
/access_log.db
|
||||||
/access_log.db.bak
|
/access_log.db.bak
|
||||||
/folder_permission_config.json
|
/folder_secret_config.json
|
||||||
/folder_mount_config.json
|
/folder_mount_config.json
|
||||||
/.env
|
/.env
|
||||||
/app_config.json
|
/app_config.json
|
||||||
|
|||||||
@ -6,6 +6,7 @@ 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 = []
|
||||||
|
|
||||||
@ -189,11 +190,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)
|
return render_template('songs_dashboard.html', timeframe=timeframe_param, performance_data=performance_data, site=site, category=category, admin_enabled=auth.is_admin())
|
||||||
|
|
||||||
@require_secret
|
@require_secret
|
||||||
def connections():
|
def connections():
|
||||||
return render_template('connections.html')
|
return render_template('connections.html', admin_enabled=auth.is_admin())
|
||||||
|
|
||||||
@require_secret
|
@require_secret
|
||||||
def dashboard():
|
def dashboard():
|
||||||
@ -476,7 +477,8 @@ 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():
|
||||||
|
|||||||
19
app.py
19
app.py
@ -9,7 +9,6 @@ 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
|
||||||
@ -22,12 +21,9 @@ 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
|
||||||
|
|
||||||
with open("app_config.json", 'r') as file:
|
app_config = auth.return_app_config()
|
||||||
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)
|
||||||
@ -53,6 +49,9 @@ 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", "")
|
||||||
@ -100,6 +99,7 @@ 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)
|
||||||
|
|
||||||
@ -437,6 +437,7 @@ def create_share(subpath):
|
|||||||
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']:
|
||||||
@ -546,16 +547,14 @@ 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=admin_enabled,
|
admin_enabled=auth.is_admin()
|
||||||
)
|
)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
58
auth.py
58
auth.py
@ -12,14 +12,42 @@ 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.json", 'r') as file:
|
with open(APP_CONFIG_FILENAME, 'r') as file:
|
||||||
app_config = json.load(file)
|
app_config = json.load(file)
|
||||||
|
|
||||||
with open('folder_permission_config.json') as file:
|
with open(FOLDER_CONFIG_FILENAME) 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)
|
||||||
@ -104,8 +132,10 @@ 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
|
||||||
@ -129,9 +159,28 @@ 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)
|
||||||
@ -197,7 +246,8 @@ 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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
42
folder_secret_config_editor.py
Normal file
42
folder_secret_config_editor.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
@ -43,6 +43,17 @@
|
|||||||
<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>
|
||||||
|
|||||||
62
templates/base.html
Normal file
62
templates/base.html
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<!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>
|
||||||
@ -1,83 +1,38 @@
|
|||||||
<!DOCTYPE html>
|
{# templates/connections.html #}
|
||||||
<html lang="en">
|
{% extends 'base.html' %}
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
{% block title %}Recent Connections{% endblock %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Recent Connections</title>
|
{% block nav_brand %}Downloads der letzten 10 Minuten{% endblock %}
|
||||||
<link
|
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
|
{% block nav_extra %}
|
||||||
rel="stylesheet"
|
<span class="ms-3">
|
||||||
/>
|
Anzahl Verbindungen: <strong id="totalConnections">0</strong>
|
||||||
|
</span>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
<style>
|
<style>
|
||||||
/* Basic layout */
|
/* page-specific styles */
|
||||||
html,
|
.table-container { flex: 1; overflow-y: auto; }
|
||||||
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% {
|
0% { opacity: 0; transform: scale(0.8); }
|
||||||
opacity: 0;
|
100% { opacity: 1; transform: scale(1); }
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
}
|
||||||
100% {
|
.slide-in { animation: slideIn 0.5s ease-out; }
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.slide-in {
|
|
||||||
animation: slideIn 0.5s ease-out;
|
|
||||||
}
|
|
||||||
/* Animation for disappearing rows */
|
|
||||||
@keyframes slideOut {
|
@keyframes slideOut {
|
||||||
0% {
|
0% { opacity: 1; transform: scale(1); }
|
||||||
opacity: 1;
|
100% { opacity: 0; transform: scale(0.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>
|
||||||
</head>
|
{% endblock %}
|
||||||
<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">
|
||||||
@ -93,39 +48,32 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="connectionsTableBody">
|
<tbody id="connectionsTableBody">
|
||||||
<!-- Rows are dynamically inserted here -->
|
{# dynamically populated via Socket.IO #}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<!-- Socket.IO client library -->
|
{% block scripts %}
|
||||||
<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) {
|
||||||
const total = data.length;
|
document.getElementById("totalConnections").textContent = data.length;
|
||||||
document.getElementById("totalConnections").textContent = total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Create a table row.
|
function createRow(record, animate=true) {
|
||||||
// When applyAnimation is true, the row uses the slide-in animation.
|
const tr = document.createElement("tr");
|
||||||
function createRow(record, applyAnimation = true) {
|
tr.dataset.timestamp = record.timestamp;
|
||||||
const row = document.createElement("tr");
|
if (animate) {
|
||||||
row.setAttribute("data-timestamp", record.timestamp);
|
tr.classList.add("slide-in");
|
||||||
if (applyAnimation) {
|
tr.addEventListener("animationend", () =>
|
||||||
row.classList.add("slide-in");
|
tr.classList.remove("slide-in"), { once: true });
|
||||||
row.addEventListener("animationend", () =>
|
|
||||||
row.classList.remove("slide-in"), { once: true });
|
|
||||||
}
|
}
|
||||||
row.innerHTML = `
|
tr.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>
|
||||||
@ -134,71 +82,47 @@
|
|||||||
<td>${record.mime_typ}</td>
|
<td>${record.mime_typ}</td>
|
||||||
<td>${record.cached}</td>
|
<td>${record.cached}</td>
|
||||||
`;
|
`;
|
||||||
return row;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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");
|
||||||
// Map current rows by their timestamp.
|
const existing = {};
|
||||||
const currentRows = {};
|
Array.from(tbody.children).forEach(r => existing[r.dataset.timestamp] = r);
|
||||||
Array.from(tbody.children).forEach(row => {
|
|
||||||
currentRows[row.getAttribute("data-timestamp")] = row;
|
const frag = document.createDocumentFragment();
|
||||||
});
|
data.forEach(rec => {
|
||||||
const fragment = document.createDocumentFragment();
|
let row = existing[rec.timestamp];
|
||||||
data.forEach(record => {
|
if (row) {
|
||||||
let row;
|
row.innerHTML = createRow(rec, false).innerHTML;
|
||||||
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 {
|
||||||
// New row: create with slide-in animation.
|
row = createRow(rec, true);
|
||||||
row = createRow(record, true);
|
|
||||||
}
|
}
|
||||||
fragment.appendChild(row);
|
frag.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
tbody.appendChild(fragment);
|
tbody.appendChild(frag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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");
|
||||||
// Identify any new records (by comparing timestamps).
|
const currentTs = new Set([...tbody.children].map(r => r.dataset.timestamp));
|
||||||
const currentTimestamps = new Set(
|
const newRecs = data.filter(r => !currentTs.has(r.timestamp));
|
||||||
Array.from(tbody.children).map(row => row.getAttribute("data-timestamp"))
|
|
||||||
);
|
|
||||||
const newRecords = data.filter(record => !currentTimestamps.has(record.timestamp));
|
|
||||||
|
|
||||||
if (newRecords.length > 0) {
|
if (newRecs.length) {
|
||||||
// Create a temporary row to measure its height.
|
const temp = createRow(newRecs[0], false);
|
||||||
const tempRow = createRow(newRecords[0], false);
|
temp.style.visibility = "hidden";
|
||||||
tempRow.style.visibility = "hidden";
|
tbody.appendChild(temp);
|
||||||
tbody.appendChild(tempRow);
|
const h = temp.getBoundingClientRect().height;
|
||||||
const newRowHeight = tempRow.getBoundingClientRect().height;
|
temp.remove();
|
||||||
tempRow.remove();
|
|
||||||
|
|
||||||
// Animate the tbody moving down by the measured height.
|
tbody.style.transform = `translateY(${h}px)`;
|
||||||
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);
|
||||||
@ -207,12 +131,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for incoming connection data from the server.
|
socket.on("recent_connections", data => {
|
||||||
socket.on("recent_connections", function(data) {
|
|
||||||
updateStats(data);
|
updateStats(data);
|
||||||
animateTableWithNewRow(data);
|
animateTableWithNewRow(data);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"></script>
|
{% endblock %}
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@ -1,42 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
{# templates/dashboard.html #}
|
||||||
<html lang="en">
|
{% extends 'base.html' %}
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
{# page title #}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
{% block title %}Dashboard{% endblock %}
|
||||||
<title>Dashboard - Verbindungsanalyse</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
{# override navbar text: #}
|
||||||
<style>
|
{% block nav_brand %}Auswertung-Downloads{% endblock %}
|
||||||
html, body {
|
|
||||||
height: 100%;
|
{# page content #}
|
||||||
margin: 0;
|
{% block content %}
|
||||||
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">
|
||||||
@ -173,7 +145,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 Nutzer</h5>
|
<h5 class="card-title">Anzahl Geräte</h5>
|
||||||
<canvas id="distinctDeviceChart"></canvas>
|
<canvas id="distinctDeviceChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -271,9 +243,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<!-- Scripts -->
|
{% block 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
|
||||||
@ -389,5 +361,4 @@
|
|||||||
options: { responsive: true }
|
options: { responsive: true }
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
{% endblock %}
|
||||||
</html>
|
|
||||||
|
|||||||
317
templates/folder_secret_config_editor.html
Normal file
317
templates/folder_secret_config_editor.html
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
{# 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 %}
|
||||||
|
|
||||||
@ -1,60 +1,37 @@
|
|||||||
<!DOCTYPE html>
|
{# templates/mylinks.html #}
|
||||||
<html lang="en">
|
{% extends 'base.html' %}
|
||||||
<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 #}
|
||||||
|
{% block title %}Meine Links{% endblock %}
|
||||||
|
|
||||||
|
{# override navbar text: #}
|
||||||
|
{#
|
||||||
|
{% block nav_brand %}
|
||||||
|
Übersicht deiner gültigen Links
|
||||||
|
{% endblock %}
|
||||||
|
#}
|
||||||
|
|
||||||
|
{# page‐specific content #}
|
||||||
|
{% block content %}
|
||||||
<div class="container-fluid">
|
<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] }}" class="card-img-top qr-code p-3" alt="QR Code for secret">
|
<img src="data:image/png;base64,{{ secret_qr_codes[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">Link öffnen</a>
|
<a href="{{ secret_url[secret] }}" class="btn btn-secondary btn-sm" style="text-decoration: none;">Link öffnen</a>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="toClipboard('{{ secret_url[secret] }}')">Link kopieren</button>
|
<button class="btn btn-secondary btn-sm"
|
||||||
|
onclick="toClipboard('{{ secret_url[secret] }}')">
|
||||||
|
Link kopieren
|
||||||
|
</button>
|
||||||
<br>
|
<br>
|
||||||
<form method="post" action="{{ url_for('remove_secret') }}" class="mt-3">
|
<form method="post" action="{{ url_for('remove_secret') }}" class="mt-3">
|
||||||
<input type="hidden" name="secret" value="{{ secret }}">
|
<input type="hidden" name="secret" value="{{ secret }}">
|
||||||
@ -77,18 +54,24 @@
|
|||||||
</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] }}" class="card-img-top qr-code p-3" alt="QR Code for token">
|
<img src="data:image/png;base64,{{ token_qr_codes[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" onclick="toClipboard('{{ token_url[token] }}')">Link kopieren</button>
|
<button class="btn btn-secondary btn-sm"
|
||||||
|
onclick="toClipboard('{{ token_url[token] }}')">
|
||||||
|
Link kopieren
|
||||||
|
</button>
|
||||||
<br>
|
<br>
|
||||||
<form method="post" action="{{ url_for('remove_token') }}" class="mt-3">
|
<form method="post" action="{{ url_for('remove_token') }}" class="mt-3">
|
||||||
<input type="hidden" name="token" value="{{ token }}">
|
<input type="hidden" name="token" value="{{ token }}">
|
||||||
@ -111,6 +94,7 @@
|
|||||||
</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.
|
||||||
@ -118,6 +102,4 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
{% endblock %}
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@ -1,42 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
{# templates/songs_dashboard.html #}
|
||||||
<html lang="en">
|
{% extends 'base.html' %}
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
{# page title #}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
{% block title %}Edit Folder Config{% endblock %}
|
||||||
<title>Dashboard - Verbindungsanalyse</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
{# override navbar text: #}
|
||||||
<style>
|
{% block nav_brand %}Dashboard - Analyse Wiederholungen{% endblock %}
|
||||||
html, body {
|
|
||||||
height: 100%;
|
{# page content #}
|
||||||
margin: 0;
|
{% block content %}
|
||||||
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">
|
||||||
@ -180,3 +152,4 @@
|
|||||||
<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 %}
|
||||||
@ -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" id="tokenLink">Direct Link</a>
|
<a href="{{ token_url }}" class="copy-btn" style="text-decoration: none;" id="tokenLink">Link öffnen</a>
|
||||||
<button class="copy-btn" onclick="toClipboard('{{ token_url }}')">Copy Link</button>
|
<button class="copy-btn" onclick="toClipboard('{{ token_url }}')">Link kopieren</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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user