Compare commits
No commits in common. "0c580090dd709b4059b5c7c92398115c01907786" and "8e503000a6ff6af766c2b26b5a5ae942402e6c0c" have entirely different histories.
0c580090dd
...
8e503000a6
66
app.py
66
app.py
@ -17,8 +17,7 @@ from functools import lru_cache
|
|||||||
from urllib.parse import urlparse, unquote
|
from urllib.parse import urlparse, unquote
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
import re
|
import re
|
||||||
import qrcode
|
|
||||||
import base64
|
|
||||||
import search
|
import search
|
||||||
import auth
|
import auth
|
||||||
import analytics as a
|
import analytics as a
|
||||||
@ -26,9 +25,6 @@ import analytics as a
|
|||||||
with open("app_config.json", 'r') as file:
|
with open("app_config.json", 'r') as file:
|
||||||
app_config = json.load(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)
|
||||||
cache_video = diskcache.Cache('./filecache_video', size_limit= app_config['filecache_size_limit_video'] * 1024**3)
|
cache_video = diskcache.Cache('./filecache_video', size_limit= app_config['filecache_size_limit_video'] * 1024**3)
|
||||||
@ -47,7 +43,6 @@ app.add_url_rule('/dashboard', view_func=a.dashboard)
|
|||||||
app.add_url_rule('/connections', view_func=a.connections)
|
app.add_url_rule('/connections', view_func=a.connections)
|
||||||
app.add_url_rule('/mylinks', view_func=auth.mylinks)
|
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_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('/search', view_func=search.search, methods=['GET'])
|
||||||
app.add_url_rule('/searchcommand', view_func=search.searchcommand, methods=['POST'])
|
app.add_url_rule('/searchcommand', view_func=search.searchcommand, methods=['POST'])
|
||||||
|
|
||||||
@ -411,58 +406,6 @@ def get_transcript(subpath):
|
|||||||
content = f.read()
|
content = f.read()
|
||||||
return content, 200, {'Content-Type': 'text/markdown; charset=utf-8'}
|
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():
|
def query_recent_connections():
|
||||||
global clients_connected, background_thread_running
|
global clients_connected, background_thread_running
|
||||||
background_thread_running = True
|
background_thread_running = True
|
||||||
@ -532,14 +475,9 @@ def handle_request_initial_data():
|
|||||||
def index(path):
|
def index(path):
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
302
auth.py
302
auth.py
@ -6,27 +6,21 @@ import os
|
|||||||
import json
|
import json
|
||||||
import qrcode
|
import qrcode
|
||||||
import base64
|
import base64
|
||||||
from typing import Dict, Any
|
|
||||||
|
|
||||||
import zlib
|
|
||||||
import hmac
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
|
|
||||||
|
folder_config = {}
|
||||||
|
|
||||||
with open("app_config.json", 'r') as file:
|
with open("app_config.json", 'r') as file:
|
||||||
app_config = json.load(file)
|
app_config = json.load(file)
|
||||||
|
|
||||||
with open('folder_permission_config.json') as file:
|
|
||||||
folder_config = json.load(file)
|
|
||||||
|
|
||||||
|
|
||||||
def require_secret(f):
|
def require_secret(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
global app_config, folder_config
|
global folder_config
|
||||||
|
if not folder_config:
|
||||||
|
with open('folder_permission_config.json') as file:
|
||||||
|
folder_config = json.load(file)
|
||||||
|
|
||||||
def is_valid_secret(config_item, provided_secret):
|
def is_valid(config_item, provided_secret):
|
||||||
"""
|
"""
|
||||||
Checks if today's date is <= validity date
|
Checks if today's date is <= validity date
|
||||||
AND if the provided secret matches config_item['secret'].
|
AND if the provided secret matches config_item['secret'].
|
||||||
@ -42,50 +36,36 @@ def require_secret(f):
|
|||||||
provided_secret == config_item['secret']
|
provided_secret == config_item['secret']
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1) Get secretand token from query params (if any)
|
# 1) Get secret from query params (if any)
|
||||||
args_secret = request.args.get('secret')
|
args_secret = request.args.get('secret')
|
||||||
args_token = request.args.get('token')
|
|
||||||
|
|
||||||
# 2) Initialize 'valid_secrets' in the session if missing
|
# 2) Initialize 'allowed_secrets' in the session if missing
|
||||||
if 'valid_secrets' not in session:
|
if 'allowed_secrets' not in session:
|
||||||
session['valid_secrets'] = []
|
session['allowed_secrets'] = []
|
||||||
|
|
||||||
if 'valid_tokens' not in session:
|
|
||||||
session['valid_tokens'] = []
|
|
||||||
|
|
||||||
# 3) If a new secret is provided, check if it’s valid, and add to session if so
|
# 3) If a new secret is provided, check if it’s valid, and add to session if so
|
||||||
if args_secret:
|
if args_secret:
|
||||||
for config_item in folder_config:
|
for config_item in folder_config:
|
||||||
if is_valid_secret(config_item, args_secret):
|
if is_valid(config_item, args_secret):
|
||||||
if args_secret not in session['valid_secrets']:
|
if args_secret not in session['allowed_secrets']:
|
||||||
session['valid_secrets'].append(args_secret)
|
session['allowed_secrets'].append(args_secret)
|
||||||
session.permanent = True # Make the session permanent
|
session.permanent = True # Make the session permanent
|
||||||
|
|
||||||
if args_token:
|
# 4) Re-check validity of each secret in session['allowed_secrets']
|
||||||
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.
|
# If a secret is no longer valid (or not in config), remove it.
|
||||||
for secret_in_session in session['valid_secrets'][:]:
|
for secret_in_session in session['allowed_secrets'][:]:
|
||||||
# Find the current config item with matching secret
|
# Find the current config item with matching secret
|
||||||
config_item = next(
|
config_item = next(
|
||||||
(c for c in folder_config if c['secret'] == secret_in_session),
|
(c for c in folder_config if c['secret'] == secret_in_session),
|
||||||
None
|
None
|
||||||
)
|
)
|
||||||
# If the config item doesn’t exist or is invalid, remove secret
|
# If the config item doesn’t exist or is invalid, remove secret
|
||||||
if config_item is None or not is_valid_secret(config_item, secret_in_session):
|
if config_item is None or not is_valid(config_item, secret_in_session):
|
||||||
session['valid_secrets'].remove(secret_in_session)
|
session['allowed_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
|
# 5) Build session['folders'] fresh from the valid secrets
|
||||||
session['folders'] = {}
|
session['folders'] = {}
|
||||||
for secret_in_session in session.get('valid_secrets', []):
|
for secret_in_session in session.get('allowed_secrets', []):
|
||||||
config_item = next(
|
config_item = next(
|
||||||
(c for c in folder_config if c['secret'] == secret_in_session),
|
(c for c in folder_config if c['secret'] == secret_in_session),
|
||||||
None
|
None
|
||||||
@ -94,20 +74,6 @@ def require_secret(f):
|
|||||||
for folder_info in config_item['folders']:
|
for folder_info in config_item['folders']:
|
||||||
session['folders'][folder_info['foldername']] = folder_info['folderpath']
|
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
|
# 6) If we have folders, proceed; otherwise show index
|
||||||
if session['folders']:
|
if session['folders']:
|
||||||
# assume since visitor has a valid secret, they are ok with annonymous tracking
|
# assume since visitor has a valid secret, they are ok with annonymous tracking
|
||||||
@ -134,24 +100,16 @@ def require_secret(f):
|
|||||||
|
|
||||||
@require_secret
|
@require_secret
|
||||||
def mylinks():
|
def mylinks():
|
||||||
scheme = request.scheme # current scheme (http or https)
|
allowed_secrets = session.get('allowed_secrets', [])
|
||||||
valid_secrets = session.get('valid_secrets', [])
|
|
||||||
valid_tokens = session.get('valid_tokens', [])
|
|
||||||
host = request.host
|
host = request.host
|
||||||
|
|
||||||
secret_qr_codes = {}
|
secret_qr_codes = {}
|
||||||
secret_folders = {}
|
secret_folders = {}
|
||||||
secret_url = {}
|
|
||||||
secret_valid_to = {}
|
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)
|
# Build a QR code for each secret (using the URL with the secret as query parameter)
|
||||||
for secret in valid_secrets:
|
for secret in allowed_secrets:
|
||||||
url = f"{scheme}://{host}?secret={secret}"
|
url = f"https://{host}?secret={secret}"
|
||||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||||
qr.add_data(url)
|
qr.add_data(url)
|
||||||
qr.make(fit=True)
|
qr.make(fit=True)
|
||||||
@ -165,218 +123,28 @@ def mylinks():
|
|||||||
# Lookup folder info and valid-to date for this secret from the global folder_config.
|
# 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)
|
config_item = next((c for c in folder_config if c['secret'] == secret), None)
|
||||||
if config_item:
|
if config_item:
|
||||||
secret_folders[secret] = config_item.get('folders')
|
secret_folders[secret] = config_item['folders']
|
||||||
secret_url[secret] = url
|
|
||||||
secret_valid_to[secret] = config_item.get('validity', 'Unbekannt')
|
secret_valid_to[secret] = config_item.get('validity', 'Unbekannt')
|
||||||
|
else:
|
||||||
for token in valid_tokens:
|
secret_folders[secret] = []
|
||||||
url = f"{scheme}://{host}?token={token}"
|
secret_valid_to[secret] = 'Unbekannt'
|
||||||
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',
|
return render_template('mylinks.html',
|
||||||
valid_secrets=valid_secrets,
|
allowed_secrets=allowed_secrets,
|
||||||
secret_qr_codes=secret_qr_codes,
|
secret_qr_codes=secret_qr_codes,
|
||||||
secret_folders=secret_folders,
|
secret_folders=secret_folders,
|
||||||
secret_url=secret_url,
|
secret_valid_to=secret_valid_to)
|
||||||
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():
|
def remove_secret():
|
||||||
secret_to_remove = request.form.get('secret')
|
secret_to_remove = request.form.get('secret')
|
||||||
valid_secrets = session.get('valid_secrets', [])
|
allowed_secrets = session.get('allowed_secrets', [])
|
||||||
if secret_to_remove in valid_secrets:
|
if secret_to_remove in allowed_secrets:
|
||||||
valid_secrets.remove(secret_to_remove)
|
allowed_secrets.remove(secret_to_remove)
|
||||||
session['valid_secrets'] = valid_secrets
|
session['allowed_secrets'] = allowed_secrets
|
||||||
return redirect(url_for('mylinks'))
|
return redirect(url_for('mylinks'))
|
||||||
|
|
||||||
def remove_token():
|
return render_template('mylinks.html',
|
||||||
token_to_remove = request.form.get('token')
|
allowed_secrets=allowed_secrets,
|
||||||
valid_tokens = session.get('valid_tokens', [])
|
secret_qr_codes=secret_qr_codes,
|
||||||
if token_to_remove in valid_tokens:
|
secret_folders=secret_folders)
|
||||||
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
Normal file
100
keygen.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
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)
|
||||||
@ -10,7 +10,6 @@ html, body {
|
|||||||
body {
|
body {
|
||||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||||
padding-top: 70px; /* Adjust to your header height */
|
padding-top: 70px; /* Adjust to your header height */
|
||||||
overflow-x: hidden; /* Prevent horizontal scroll */
|
|
||||||
}
|
}
|
||||||
/* Header styles */
|
/* Header styles */
|
||||||
.site-header {
|
.site-header {
|
||||||
@ -18,7 +17,7 @@ body {
|
|||||||
color: var(--header-text-color);
|
color: var(--header-text-color);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: 100%;
|
width: 94%;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -100,11 +99,17 @@ div.directory-item a, li.directory-item a, li.file-item a, li.link-item a {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.directories-grid .directory-item {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
text-align: center;
|
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 Styles (for both music and image files) */
|
||||||
.file-item, .directory-item{
|
.file-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -133,16 +138,6 @@ a.show-transcript:hover {
|
|||||||
color: rgb(113, 146, 167);
|
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 {
|
.currently-playing {
|
||||||
background-color: var(--selected-background);
|
background-color: var(--selected-background);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,33 +74,22 @@ function renderContent(data) {
|
|||||||
});
|
});
|
||||||
contentHTML += '</div>';
|
contentHTML += '</div>';
|
||||||
} else {
|
} else {
|
||||||
share_link = '';
|
|
||||||
// Render directories normally
|
// Render directories normally
|
||||||
if (data.directories.length > 0) {
|
if (data.directories.length > 0) {
|
||||||
const areAllShort = data.directories.every(dir => dir.name.length <= 10) && data.files.length === 0;
|
const areAllShort = data.directories.every(dir => dir.name.length <= 15) && data.files.length === 0;
|
||||||
if (areAllShort && data.breadcrumbs.length !== 1) {
|
if (areAllShort && data.breadcrumbs.length !== 1) {
|
||||||
contentHTML += '<div class="directories-grid">';
|
contentHTML += '<div class="directories-grid">';
|
||||||
data.directories.forEach(dir => {
|
data.directories.forEach(dir => {
|
||||||
if (admin_enabled && data.breadcrumbs.length != 1) {
|
contentHTML += `<div class="directory-item">📁 <a href="#" class="directory-link" data-path="${dir.path}">${dir.name}</a></div>`;
|
||||||
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>';
|
contentHTML += '</div>';
|
||||||
} else {
|
} else {
|
||||||
contentHTML += '<ul>';
|
contentHTML += '<ul>';
|
||||||
data.directories.forEach(dir => {
|
data.directories.forEach(dir => {
|
||||||
if (admin_enabled && data.breadcrumbs.length != 1) {
|
contentHTML += `<li class="directory-item">📁 <a href="#" class="directory-link" data-path="${dir.path}">${dir.name}</a></li>`;
|
||||||
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) {
|
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>';
|
contentHTML += '</ul>';
|
||||||
}
|
}
|
||||||
@ -121,7 +110,7 @@ function renderContent(data) {
|
|||||||
const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
|
const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
|
||||||
contentHTML += `<li class="file-item">
|
contentHTML += `<li class="file-item">
|
||||||
<a href="#" class="play-file"${indexAttr} data-url="${file.path}" data-file-type="${file.file_type}">${symbol} ${file.name.replace('.mp3', '')}</a>`;
|
<a href="#" class="play-file"${indexAttr} data-url="${file.path}" data-file-type="${file.file_type}">${symbol} ${file.name.replace('.mp3', '')}</a>`;
|
||||||
if (file.has_transcript && admin_enabled) {
|
if (file.has_transcript) {
|
||||||
contentHTML += `<a href="#" class="show-transcript" data-url="${file.transcript_url}" title="Show Transcript">📄</a>`;
|
contentHTML += `<a href="#" class="show-transcript" data-url="${file.transcript_url}" title="Show Transcript">📄</a>`;
|
||||||
}
|
}
|
||||||
contentHTML += `</li>`;
|
contentHTML += `</li>`;
|
||||||
@ -373,32 +362,6 @@ 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.
|
// Modal close logic for transcript modal.
|
||||||
@ -542,34 +505,4 @@ function syncThemeColor() {
|
|||||||
.forEach(svg => svg.setAttribute('fill', cssVar));
|
.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);
|
document.addEventListener('DOMContentLoaded', syncThemeColor);
|
||||||
@ -32,7 +32,6 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='gallery.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='gallery.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='audioplayer.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='audioplayer.css') }}">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
<script> const admin_enabled = {{ admin_enabled | tojson | safe }}; </script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
<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='theme.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='app.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>
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -21,7 +20,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="alert alert-warning">Du hast keine Links die noch gültig sind.<br>Bitte den Freigabelink erneut anklicken.</div>
|
<div id="content">Du hast keine Links die noch gültig sind.<br>Bitte den Freigabelink erneut anklicken.</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -41,9 +41,9 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
{% if allowed_secrets %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% if valid_secrets %}
|
{% for secret in allowed_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">
|
||||||
@ -51,7 +51,7 @@
|
|||||||
<h5 class="card-title">Geheimnis: {{ secret }}</h5>
|
<h5 class="card-title">Geheimnis: {{ secret }}</h5>
|
||||||
<p class="card-text">
|
<p class="card-text">
|
||||||
<a href="https://{{ request.host }}?secret={{ secret }}" class="text-decoration-none">
|
<a href="https://{{ request.host }}?secret={{ secret }}" class="text-decoration-none">
|
||||||
{{ secret_url[secret] }}
|
https://{{ request.host }}?secret={{ secret }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p class="card-text">
|
<p class="card-text">
|
||||||
@ -77,50 +77,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
</div>
|
||||||
{% 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 %}
|
{% 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">
|
<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>
|
|
||||||
<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>
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user