Compare commits

...

5 Commits

Author SHA1 Message Date
0c580090dd fix misplacement 2025-05-01 19:46:46 +00:00
7b73facc16 update token generation gui 2025-05-01 21:37:29 +02:00
4b37684593 fix error 2025-05-01 17:24:23 +00:00
fef89666df implement sharing in gui 2025-05-01 19:04:18 +02:00
77b9221320 add token based foldershare 2025-05-01 12:01:14 +02:00
9 changed files with 521 additions and 163 deletions

66
app.py
View File

@ -17,7 +17,8 @@ from functools import lru_cache
from urllib.parse import urlparse, unquote
from werkzeug.middleware.proxy_fix import ProxyFix
import re
import qrcode
import base64
import search
import auth
import analytics as a
@ -25,6 +26,9 @@ import analytics as a
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_image = diskcache.Cache('./filecache_image', size_limit= app_config['filecache_size_limit_image'] * 1024**3)
cache_video = diskcache.Cache('./filecache_video', size_limit= app_config['filecache_size_limit_video'] * 1024**3)
@ -43,6 +47,7 @@ app.add_url_rule('/dashboard', view_func=a.dashboard)
app.add_url_rule('/connections', view_func=a.connections)
app.add_url_rule('/mylinks', view_func=auth.mylinks)
app.add_url_rule('/remove_secret', view_func=auth.remove_secret, methods=['POST'])
app.add_url_rule('/remove_token', view_func=auth.remove_token, methods=['POST'])
app.add_url_rule('/search', view_func=search.search, methods=['GET'])
app.add_url_rule('/searchcommand', view_func=search.searchcommand, methods=['POST'])
@ -406,6 +411,58 @@ def get_transcript(subpath):
content = f.read()
return content, 200, {'Content-Type': 'text/markdown; charset=utf-8'}
@app.route("/create_share/<path:subpath>")
@auth.require_secret
def create_share(subpath):
scheme = request.scheme # current scheme (http or https)
host = request.host
if 'admin ' not in session and not session.get('admin'):
return "Unauthorized", 403
paths = {}
for item in folder_config:
for folder in item['folders']:
paths[folder['foldername']] = folder['folderpath']
# use folder config file and ignore validity to get full path
root, *relative_parts = subpath.split('/')
base_path = paths[root]
full_path = os.path.join(base_path, *relative_parts)
foldername = relative_parts[-1]
validity_date = (datetime.now() + timedelta(days=90)).strftime('%d.%m.%Y')
data = {
"validity": validity_date,
"folders": [
{
"foldername": foldername,
"folderpath": full_path
}
]
}
token = auth.generate_secret_key_compressed(data)
url = f"{scheme}://{host}?token={token}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode('ascii')
token_item = auth.decode_secret_key_compressed(token)
return render_template('view_token.html',
token_qr_code=img_base64,
token_folder=token_item.get('folders'),
token_url=url,
token_valid_to=token_item.get('validity', 'Unbekannt')
)
def query_recent_connections():
global clients_connected, background_thread_running
background_thread_running = True
@ -475,9 +532,14 @@ def handle_request_initial_data():
def index(path):
title_short = app_config.get('TITLE_SHORT', '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",
title_short=title_short,
title_long=title_long
title_long=title_long,
admin_enabled=admin_enabled,
)
if __name__ == '__main__':

306
auth.py
View File

@ -6,21 +6,27 @@ import os
import json
import qrcode
import base64
from typing import Dict, Any
import zlib
import hmac
import hashlib
folder_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)
def require_secret(f):
@wraps(f)
def decorated_function(*args, **kwargs):
global folder_config
if not folder_config:
with open('folder_permission_config.json') as file:
folder_config = json.load(file)
global app_config, folder_config
def is_valid(config_item, provided_secret):
def is_valid_secret(config_item, provided_secret):
"""
Checks if today's date is <= validity date
AND if the provided secret matches config_item['secret'].
@ -36,36 +42,50 @@ def require_secret(f):
provided_secret == config_item['secret']
)
# 1) Get secret from query params (if any)
# 1) Get secretand token from query params (if any)
args_secret = request.args.get('secret')
args_token = request.args.get('token')
# 2) Initialize 'allowed_secrets' in the session if missing
if 'allowed_secrets' not in session:
session['allowed_secrets'] = []
# 2) Initialize 'valid_secrets' in the session if missing
if 'valid_secrets' not in session:
session['valid_secrets'] = []
if 'valid_tokens' not in session:
session['valid_tokens'] = []
# 3) If a new secret is provided, check if its valid, and add to session if so
if args_secret:
for config_item in folder_config:
if is_valid(config_item, args_secret):
if args_secret not in session['allowed_secrets']:
session['allowed_secrets'].append(args_secret)
if is_valid_secret(config_item, args_secret):
if args_secret not in session['valid_secrets']:
session['valid_secrets'].append(args_secret)
session.permanent = True # Make the session permanent
# 4) Re-check validity of each secret in session['allowed_secrets']
if args_token:
if is_valid_token(args_token):
if args_token not in session['valid_tokens']:
session['valid_tokens'].append(args_token)
session.permanent = True # Make the session permanent
# 4) Re-check validity of each secret and token to also catch previous ones
# If a secret is no longer valid (or not in config), remove it.
for secret_in_session in session['allowed_secrets'][:]:
for secret_in_session in session['valid_secrets'][:]:
# Find the current config item with matching secret
config_item = next(
(c for c in folder_config if c['secret'] == secret_in_session),
None
)
# If the config item doesnt exist or is invalid, remove secret
if config_item is None or not is_valid(config_item, secret_in_session):
session['allowed_secrets'].remove(secret_in_session)
if config_item is None or not is_valid_secret(config_item, secret_in_session):
session['valid_secrets'].remove(secret_in_session)
for token_in_session in session['valid_tokens'][:]:
if not is_valid_token(token_in_session):
session['valid_tokens'].remove(token_in_session)
# 5) Build session['folders'] fresh from the valid secrets
session['folders'] = {}
for secret_in_session in session.get('allowed_secrets', []):
for secret_in_session in session.get('valid_secrets', []):
config_item = next(
(c for c in folder_config if c['secret'] == secret_in_session),
None
@ -74,6 +94,20 @@ def require_secret(f):
for folder_info in config_item['folders']:
session['folders'][folder_info['foldername']] = folder_info['folderpath']
for token_in_session in session.get('valid_tokens', []):
token_item = decode_secret_key_compressed(token_in_session)
for folder_info in token_item['folders']:
session['folders'][folder_info['foldername']] = folder_info['folderpath']
args_admin = request.args.get('admin', None)
config_admin = app_config.get('ADMIN_KEY', None)
if args_admin and config_admin:
if args_admin == config_admin:
session['admin'] = True
else:
session['admin'] = False
# 6) If we have folders, proceed; otherwise show index
if session['folders']:
# assume since visitor has a valid secret, they are ok with annonymous tracking
@ -100,16 +134,24 @@ def require_secret(f):
@require_secret
def mylinks():
allowed_secrets = session.get('allowed_secrets', [])
scheme = request.scheme # current scheme (http or https)
valid_secrets = session.get('valid_secrets', [])
valid_tokens = session.get('valid_tokens', [])
host = request.host
secret_qr_codes = {}
secret_folders = {}
secret_url = {}
secret_valid_to = {}
token_qr_codes = {}
token_folders = {}
token_url = {}
token_valid_to = {}
# Build a QR code for each secret (using the URL with the secret as query parameter)
for secret in allowed_secrets:
url = f"https://{host}?secret={secret}"
for secret in valid_secrets:
url = f"{scheme}://{host}?secret={secret}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(url)
qr.make(fit=True)
@ -123,28 +165,218 @@ def mylinks():
# Lookup folder info and valid-to date for this secret from the global folder_config.
config_item = next((c for c in folder_config if c['secret'] == secret), None)
if config_item:
secret_folders[secret] = config_item['folders']
secret_folders[secret] = config_item.get('folders')
secret_url[secret] = url
secret_valid_to[secret] = config_item.get('validity', 'Unbekannt')
else:
secret_folders[secret] = []
secret_valid_to[secret] = 'Unbekannt'
for token in valid_tokens:
url = f"{scheme}://{host}?token={token}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode('ascii')
token_qr_codes[token] = img_base64
token_item = decode_secret_key_compressed(token)
token_folders[token] = token_item.get('folders')
token_url[token] = url
token_valid_to[token] = token_item.get('validity', 'Unbekannt')
return render_template('mylinks.html',
allowed_secrets=allowed_secrets,
secret_qr_codes=secret_qr_codes,
secret_folders=secret_folders,
secret_valid_to=secret_valid_to)
valid_secrets=valid_secrets,
secret_qr_codes=secret_qr_codes,
secret_folders=secret_folders,
secret_url=secret_url,
secret_valid_to=secret_valid_to,
valid_tokens=valid_tokens,
token_qr_codes=token_qr_codes,
token_folders=token_folders,
token_url=token_url,
token_valid_to=token_valid_to
)
def remove_secret():
secret_to_remove = request.form.get('secret')
allowed_secrets = session.get('allowed_secrets', [])
if secret_to_remove in allowed_secrets:
allowed_secrets.remove(secret_to_remove)
session['allowed_secrets'] = allowed_secrets
valid_secrets = session.get('valid_secrets', [])
if secret_to_remove in valid_secrets:
valid_secrets.remove(secret_to_remove)
session['valid_secrets'] = valid_secrets
return redirect(url_for('mylinks'))
return render_template('mylinks.html',
allowed_secrets=allowed_secrets,
secret_qr_codes=secret_qr_codes,
secret_folders=secret_folders)
def remove_token():
token_to_remove = request.form.get('token')
valid_tokens = session.get('valid_tokens', [])
if token_to_remove in valid_tokens:
valid_tokens.remove(token_to_remove)
session['valid_tokens'] = valid_tokens
return redirect(url_for('mylinks'))
def is_valid_token(secret_key: str, hmac_length: int = 32) -> bool:
"""
Check whether the token is authentic _and_ its payload's "validity"
date has not yet passed.
Returns True if:
- signature matches (via decode_secret_key_compressed)
- the payload contains a "validity" field in DD.MM.YYYY format
- today's date <= validity date
Otherwise returns False.
"""
try:
payload = decode_secret_key_compressed(secret_key, hmac_length)
except ValueError:
return False
validity = payload.get("validity")
if not isinstance(validity, str):
return False
try:
day, month, year = map(int, validity.split('.'))
expiry = datetime(year, month, day).date()
except Exception:
# malformed date
return False
return datetime.now().date() <= expiry
# Define your key map: long → short
KEY_MAP = {
"validity": "v",
"folders": "f",
"foldername": "n",
"folderpath": "p",
}
# Build the inverse map automatically
INV_KEY_MAP = {short: long for long, short in KEY_MAP.items()}
def _shorten_keys(obj: Any, mapping: Dict[str,str]) -> Any:
"""Recursively replace dict keys via mapping."""
if isinstance(obj, dict):
return {
mapping.get(k, k): _shorten_keys(v, mapping)
for k, v in obj.items()
}
elif isinstance(obj, list):
return [_shorten_keys(v, mapping) for v in obj]
else:
return obj
def generate_secret_key_compressed(
payload: dict,
hmac_length: int = 32
) -> str:
"""
Compress & sign a JSON payload, with short keys and variable HMAC slice.
Args:
payload: your original dict
hmac_length: how many chars of the URL-safe base64 HMAC to include
Returns:
A token: base64(zlib(json_short)) + signature[:hmac_length] + length_hex(4)
"""
salt = app_config["SALT"]
# 1) Shorten keys, dump to bytes
short = _shorten_keys(payload, KEY_MAP)
raw = json.dumps(short, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
# 2) Compress
comp = zlib.compress(raw, level=9)
# 3) Base64-url encode, strip padding
encoded = base64.urlsafe_b64encode(comp).decode('utf-8').rstrip('=')
# 4) 4-digit hex length of compressed bytes
length_hex = f"{len(comp):04X}"
# 5) HMAC over (encoded + length_hex)
msg = (encoded + length_hex).encode('utf-8')
digest = hmac.new(salt.encode(), msg, hashlib.sha256).digest()
full_sig = base64.urlsafe_b64encode(digest).decode('utf-8')
signature = full_sig[:hmac_length]
return f"{encoded}{signature}{length_hex}"
def decode_secret_key_compressed(
secret_key: str,
hmac_length: int = 32
) -> dict:
"""
Reverse of generate_secret_key_compressed:
- verify signature (using same hmac_length)
- base64-decode zlib-decompress JSON
- restore original keys
"""
salt = app_config["SALT"]
min_len = hmac_length + 4
if len(secret_key) < min_len:
raise ValueError("Key too short.")
# Extract pieces
length_hex = secret_key[-4:]
signature = secret_key[-(hmac_length+4):-4]
encoded = secret_key[:-(hmac_length+4)]
try:
comp_len = int(length_hex, 16)
except ValueError:
raise ValueError("Invalid length prefix.")
# Verify HMAC
msg = (encoded + length_hex).encode('utf-8')
expected = hmac.new(salt.encode(), msg, hashlib.sha256).digest()
full_sig = base64.urlsafe_b64encode(expected).decode('utf-8')
if not hmac.compare_digest(signature, full_sig[:hmac_length]):
raise ValueError("Invalid signature.")
# Decode & decompress
padding = '=' * (-len(encoded) % 4)
comp = base64.urlsafe_b64decode(encoded + padding)
if len(comp) != comp_len:
raise ValueError("Compressed-length mismatch.")
raw = zlib.decompress(comp).decode('utf-8')
# Load JSON and restore long keys
short = json.loads(raw)
def _restore(obj: Any) -> Any:
if isinstance(obj, dict):
return {
INV_KEY_MAP.get(k, k): _restore(v)
for k, v in obj.items()
}
elif isinstance(obj, list):
return [_restore(v) for v in obj]
else:
return obj
return _restore(short)
# --- example usage ---
if __name__ == '__main__':
payload = {
"validity": "31.12.2025",
"folders": [
{
"foldername": "20.04.2025 Ostersonntag",
"folderpath": r"\\192.168.10.10\docker2\sync-bethaus\syncfiles\folders\Gottesdienste Speyer\2025\04. April\20.04.2025 Ostersonntag"
}
]
}
token = generate_secret_key_compressed(payload)
print("Token:", token)
result = decode_secret_key_compressed(token)
print("Decoded payload:", result)

100
keygen.py
View File

@ -1,100 +0,0 @@
import hmac
import hashlib
import base64
salt = "UqEEf08yMDE3qIQodAGAyNQ2EPFhb26f2o85MTIyMeAAkqlANiXcF"
def generate_secret_key(identifier: str, expiry: str) -> str:
"""
Generate a secret key with the following structure:
- encoded_data: Base64 encoding of (identifier + expiry)
- signature: 32 characters from the URL-safe base64 HMAC signature
- id_length_hex: 2-character hexadecimal number representing the length of the identifier
The HMAC is computed over (encoded_data + id_length_hex).
"""
global salt
if len(identifier) > 255:
raise ValueError("Identifier must be at most 255 characters long.")
if len(expiry) != 8:
raise ValueError("Expiry must be an 8-character string in DDMMYYYY format.")
# Concatenate identifier and expiry, then base64 encode the result.
plain_data = identifier + expiry
# Remove padding for compactness; we'll add it back during decoding.
encoded_data = base64.urlsafe_b64encode(plain_data.encode()).decode().rstrip("=")
# Format the length of the identifier as a 2-digit hexadecimal string.
id_length_hex = f"{len(identifier):02X}"
# Build the message for HMAC: encoded_data + id_length_hex.
message = encoded_data + id_length_hex
# Compute HMAC using SHA-256.
hmac_digest = hmac.new(salt.encode(), message.encode(), hashlib.sha256).digest()
# Get a URL-safe base64 representation and take the first 32 characters as the signature.
signature = base64.urlsafe_b64encode(hmac_digest).decode()[:32]
# Construct the final secret key.
secret_key = encoded_data + signature + id_length_hex
return secret_key
def decode_secret_key(secret_key: str):
"""
Decode the secret key and extract the identifier and expiry.
Token structure:
- encoded_data: secret_key[0 : (len(secret_key)-34)] (variable length)
- signature: secret_key[-34:-2] (32 characters)
- id_length_hex: secret_key[-2:] (2 characters)
The HMAC is verified over (encoded_data + id_length_hex). Then, encoded_data is base64-decoded
(with proper padding added) to yield (identifier + expiry), where the last 8 characters represent expiry.
The identifier length is determined from id_length_hex.
"""
global salt
if len(secret_key) < 34: # Must at least have signature (32) + id_length_hex (2)
raise ValueError("Invalid key length.")
# Extract the last 2 characters: id_length_hex.
id_length_hex = secret_key[-2:]
try:
id_length = int(id_length_hex, 16)
except ValueError:
raise ValueError("Invalid identifier length prefix in key.")
# The signature is the 32 characters preceding the last 2.
signature = secret_key[-34:-2]
# The remainder is the encoded_data.
encoded_data = secret_key[:-34]
# Verify the signature.
message = encoded_data + id_length_hex
expected_hmac = hmac.new(salt.encode(), message.encode(), hashlib.sha256).digest()
expected_signature = base64.urlsafe_b64encode(expected_hmac).decode()[:32]
if not hmac.compare_digest(signature, expected_signature):
raise ValueError("Invalid key signature.")
# Base64-decode the encoded_data.
# Add back any missing '=' padding.
padding = '=' * (-len(encoded_data) % 4)
plain_data = base64.urlsafe_b64decode(encoded_data + padding).decode()
# plain_data should be (identifier + expiry), where expiry is the last 8 characters.
if len(plain_data) != id_length + 8:
raise ValueError("Decoded data length does not match expected identifier length and expiry.")
identifier = plain_data[:id_length]
expiry = plain_data[id_length:]
return identifier, expiry
if __name__ == '__main__':
key = generate_secret_key("Besondere Gottesdienste", "30042025")
print(key)
identifier, expiry = decode_secret_key(key)
print("identifier:", identifier)
print("expiry:", expiry)

View File

@ -10,6 +10,7 @@ html, body {
body {
font-family: 'Helvetica Neue', Arial, sans-serif;
padding-top: 70px; /* Adjust to your header height */
overflow-x: hidden; /* Prevent horizontal scroll */
}
/* Header styles */
.site-header {
@ -17,7 +18,7 @@ body {
color: var(--header-text-color);
position: fixed;
top: 0;
width: 94%;
width: 100%;
z-index: 1000;
display: flex;
align-items: center;
@ -99,17 +100,11 @@ div.directory-item a, li.directory-item a, li.file-item a, li.link-item a {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.directories-grid .directory-item {
background-color: #fff;
padding: 15px;
border-radius: 5px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* File Item Styles (for both music and image files) */
.file-item {
.file-item, .directory-item{
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
@ -138,6 +133,16 @@ a.show-transcript:hover {
color: rgb(113, 146, 167);
}
a.create-share {
text-decoration: none;
color: var(--main-text-color);
font-size: 20px;
margin-left: 10px;
}
a.create-share:hover {
color: rgb(113, 146, 167);
}
.currently-playing {
background-color: var(--selected-background);
}

View File

@ -74,22 +74,33 @@ function renderContent(data) {
});
contentHTML += '</div>';
} else {
share_link = '';
// Render directories normally
if (data.directories.length > 0) {
const areAllShort = data.directories.every(dir => dir.name.length <= 15) && data.files.length === 0;
const areAllShort = data.directories.every(dir => dir.name.length <= 10) && data.files.length === 0;
if (areAllShort && data.breadcrumbs.length !== 1) {
contentHTML += '<div class="directories-grid">';
data.directories.forEach(dir => {
contentHTML += `<div class="directory-item">📁 <a href="#" class="directory-link" data-path="${dir.path}">${dir.name}</a></div>`;
if (admin_enabled && data.breadcrumbs.length != 1) {
share_link = `<a href="#" class="create-share" data-url="${dir.path}">⚙️</a>`;
}
contentHTML += `<div class="directory-item"><a href="#" class="directory-link" data-path="${dir.path}">📁 ${dir.name}</a>
${share_link}
</div>`;
});
contentHTML += '</div>';
} else {
contentHTML += '<ul>';
data.directories.forEach(dir => {
contentHTML += `<li class="directory-item">📁 <a href="#" class="directory-link" data-path="${dir.path}">${dir.name}</a></li>`;
if (admin_enabled && data.breadcrumbs.length != 1) {
share_link = `<a href="#" class="create-share" data-url="${dir.path}">⚙️</a>`;
}
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="${dir.path}">📁 ${dir.name}</a>
${share_link}
</li>`;
});
if (data.breadcrumbs.length === 1) {
contentHTML += `<li class="link-item" onclick="window.location.href='/search'">🔎 <a href="/search" class="link-link">Suche</a></li>`;
contentHTML += `<li class="link-item" onclick="window.location.href='/search'"><a href="/search" class="link-link">🔎 Suche</a></li>`;
}
contentHTML += '</ul>';
}
@ -110,7 +121,7 @@ function renderContent(data) {
const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
contentHTML += `<li class="file-item">
<a href="#" class="play-file"${indexAttr} data-url="${file.path}" data-file-type="${file.file_type}">${symbol} ${file.name.replace('.mp3', '')}</a>`;
if (file.has_transcript) {
if (file.has_transcript && admin_enabled) {
contentHTML += `<a href="#" class="show-transcript" data-url="${file.transcript_url}" title="Show Transcript">&#128196;</a>`;
}
contentHTML += `</li>`;
@ -362,6 +373,32 @@ document.querySelectorAll('.play-file').forEach(link => {
});
});
});
// create share icon clicks.
document.querySelectorAll('.create-share').forEach(link => {
link.addEventListener('click', function (event) {
event.preventDefault();
const url = '/create_share/' + this.getAttribute('data-url');
console.log(url);
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.text();
})
.then(data => {
console.log(data);
document.getElementById('transcriptContent').innerHTML = data;
document.getElementById('transcriptModal').style.display = 'block';
})
.catch(error => {
console.error('Error creating share:', error);
document.getElementById('transcriptContent').innerHTML = "<p>You can't share this.</p>";
document.getElementById('transcriptModal').style.display = 'block';
});
});
});
}
// Modal close logic for transcript modal.
@ -505,4 +542,34 @@ function syncThemeColor() {
.forEach(svg => svg.setAttribute('fill', cssVar));
}
function copyTokenUrl(url) {
if (navigator.clipboard && window.isSecureContext) {
// Modern approach
navigator.clipboard.writeText(url)
.then(() => {
alert('Token-URL in die Zwischenablage kopiert!');
})
.catch(err => {
console.error('Fehler beim Kopieren: ', err);
});
} else {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = url;
textarea.style.position = 'fixed'; // Verhindert Scrollen
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand('copy');
alert('Token-URL in die Zwischenablage kopiert!');
} catch (err) {
console.error('Fallback: Kopieren fehlgeschlagen', err);
}
document.body.removeChild(textarea);
}
}
document.addEventListener('DOMContentLoaded', syncThemeColor);

View File

@ -32,6 +32,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='gallery.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='audioplayer.css') }}">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script> const admin_enabled = {{ admin_enabled | tojson | safe }}; </script>
</head>
<body>
<header class="site-header">

View File

@ -11,6 +11,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body>
@ -20,7 +21,7 @@
</header>
<div class="container">
<div id="content">Du hast keine Links die noch gültig sind.<br>Bitte den Freigabelink erneut anklicken.</div>
<div class="alert alert-warning">Du hast keine Links die noch gültig sind.<br>Bitte den Freigabelink erneut anklicken.</div>
</div>
</body>
</html>

View File

@ -41,9 +41,9 @@
</nav>
<div class="container-fluid">
{% if allowed_secrets %}
<div class="row">
{% for secret in allowed_secrets %}
<div class="row">
{% if valid_secrets %}
{% for secret in valid_secrets %}
<div class="col-md-4 mb-4">
<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">
@ -51,7 +51,7 @@
<h5 class="card-title">Geheimnis: {{ secret }}</h5>
<p class="card-text">
<a href="https://{{ request.host }}?secret={{ secret }}" class="text-decoration-none">
https://{{ request.host }}?secret={{ secret }}
{{ secret_url[secret] }}
</a>
</p>
<p class="card-text">
@ -77,12 +77,49 @@
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-warning" role="alert">
Du hast aktuell keine gültigen Links.
</div>
{% endif %}
{% endif %}
{% if valid_tokens %}
{% for token in valid_tokens %}
<div class="col-md-4 mb-4">
<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">
<div class="card-body">
<h5 class="card-title">Token-Link:</h5>
<p class="card-text">
<a href="https://{{ request.host }}?token={{ token }}" class="text-decoration-none">
{{ token_url[token] }}
</a>
</p>
<p class="card-text">
<small class="text-muted">Gültig bis: {{ token_valid_to[token] }}</small>
</p>
<form method="post" action="{{ url_for('remove_token') }}" class="mt-3">
<input type="hidden" name="token" value="{{ token }}">
<button type="submit" class="btn btn-danger btn-sm">Link entfernen</button>
</form>
{% if token_folders[token] %}
<h6 class="mt-3">Ordner</h6>
<ul class="list-group list-group-flush">
{% for folder in token_folders[token] %}
<li class="list-group-item">
<strong>{{ folder.foldername }}</strong>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">Keine Ordner für dieses Gemeimnis hinterlegt.</p>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% endif %}
{% if not valid_secrets and not valid_tokens %}
<div class="alert alert-warning" role="alert">
Du hast aktuell keine gültigen Links.
</div>
{% endif %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>

53
templates/view_token.html Normal file
View File

@ -0,0 +1,53 @@
<style>
.container-fluid {
height: 100%;
display: flex;
flex-direction: column;
}
.qr-code {
max-width: 300px;
display: block;
margin: auto;
}
.copy-btn {
margin-top: 1rem;
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
font-size: 1rem;
cursor: pointer;
border: 1px solid #007bff;
background-color: #007bff;
color: white;
border-radius: 0.25rem;
}
.copy-btn:active {
background-color: #0056b3;
border-color: #0056b3;
}
</style>
<div class="container-fluid">
<div class="card h-100 shadow-sm">
<img src="data:image/png;base64,{{ token_qr_code }}" class="card-img-top qr-code p-3" alt="QR Code for token">
<div class="card-body">
<div class="card-text mt-2">
<h2 class="card-title">Token-Link:</h2>
<a href="{{ token_url }}" class="copy-btn" id="tokenLink">Direct Link</a>
<button class="copy-btn" onclick="copyTokenUrl('{{ token_url }}')">Copy Link</button>
</div>
<div class="card-text mt-2">
<h2 class="mt-3">Gültig bis:</h2>
{{ token_valid_to }}
</div>
<div class="card-text mt-2">
<h2 class="mt-3">Ordner:</h2>
{% for folder in token_folder %}
{{ folder.foldername }}<br>
{% endfor %}
</div>
</div>
</div>
</div>