diff --git a/app.py b/app.py index e678b70..d44ffc2 100755 --- a/app.py +++ b/app.py @@ -20,15 +20,18 @@ import search import auth import analytics as a -cache_audio = diskcache.Cache('./filecache_audio', size_limit= 48 * 1024**3) # 48 GB limit -cache_image = diskcache.Cache('./filecache_image', size_limit= 48 * 1024**3) # 48 GB limit -cache_video = diskcache.Cache('./filecache_video', size_limit= 48 * 1024**3) # 48 GB limit -cache_other = diskcache.Cache('./filecache_other', size_limit= 48 * 1024**3) # 48 GB limit +with open("app_config.json", 'r') as file: + app_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) +cache_other = diskcache.Cache('./filecache_other', size_limit= app_config['filecache_size_limit_other'] * 1024**3) app = Flask(__name__) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) -app.config['SECRET_KEY'] = '85c1117eb3a5f2c79f0ff395bada8ff8d9a257b99ef5e143' +app.config['SECRET_KEY'] = app_config['SECRET_KEY'] app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=90) if os.environ.get('FLASK_ENV') == 'production': app.config['SESSION_COOKIE_SAMESITE'] = 'None' @@ -65,7 +68,7 @@ thread_lock = threading.Lock() @lru_cache(maxsize=10) def get_cached_image(size): dimensions = tuple(map(int, size.split('-')[1].split('x'))) - original_logo_path = os.path.join(app.root_path, 'static', 'logo.png') + original_logo_path = os.path.join(app.root_path, 'custom_logo', 'logoB.png') with Image.open(original_logo_path) as img: img = img.convert("RGBA") @@ -153,7 +156,7 @@ def generate_breadcrumbs(subpath=None): breadcrumbs.append({'name': part, 'path': path_accum}) return breadcrumbs -@app.route('/static/icons/.png') +@app.route('/icon/.png') def serve_resized_icon(size): cached_image_bytes = get_cached_image(size) response = send_file( @@ -163,6 +166,22 @@ def serve_resized_icon(size): response.headers['Cache-Control'] = 'public, max-age=86400' return response +@app.route('/custom_logo/.png') +def custom_logo(filename): + file_path = os.path.join('custom_logo', f"{filename}.png") + if not os.path.exists(file_path): + abort(404) + with open(file_path, 'rb') as file: + image_data = file.read() + + # Create a BytesIO object using the binary image data + image_io = io.BytesIO(image_data) + image_io.seek(0) # Important: reset the stream position + + response = send_file(image_io, mimetype='image/png') + response.headers['Cache-Control'] = 'public, max-age=86400' + return response + @app.route('/sw.js') def serve_sw(): return send_from_directory(os.path.join(app.root_path, 'static'), 'sw.js', mimetype='application/javascript') @@ -217,96 +236,115 @@ def api_browse(subpath): @app.route("/media/") @auth.require_secret -def serve_file(subpath): +def serve_file(subpath): + # ─── 1) Locate the real file on disk ─── root, *relative_parts = subpath.split('/') - base_path = session['folders'][root] - full_path = os.path.join(base_path, *relative_parts) - + base_path = session['folders'].get(root) + full_path = os.path.join(base_path or '', *relative_parts) + if not os.path.isfile(full_path): app.logger.error(f"File not found: {full_path}") return "File not found", 404 + # ─── 2) Prep request info ─── mime, _ = mimetypes.guess_type(full_path) mime = mime or 'application/octet-stream' - range_header = request.headers.get('Range') - ip_address = request.remote_addr - user_agent = request.headers.get('User-Agent') - is_cache_request = request.headers.get('X-Cache-Request') == 'true' - # Check cache first (using diskcache) - response = None - - # determine the cache to use based on the file type - if mime and mime.startswith('audio/'): - cache = cache_audio - elif mime and mime.startswith('image/'): - cache = cache_image - elif mime and mime.startswith('video/'): - cache = cache_video - else: - cache = cache_other - - # Check if the file is already cached - if is_cache_request: - logging = False - else: - logging = True - - cached = cache.get(subpath) - if cached: - cached_file_bytes, mime = cached - cached_file = io.BytesIO(cached_file_bytes) - filesize = len(cached_file.getbuffer()) - response = send_file(cached_file, mimetype=mime) - else: - if mime and mime.startswith('image/'): - # Image processing branch (with caching) + is_cache_request = request.headers.get('X-Cache-Request') == 'true' + ip_address = request.remote_addr + user_agent = request.headers.get('User-Agent') + + # skip logging on cache hits or on audio GETs (per your rules) + do_log = not is_cache_request + if mime == 'audio/mpeg' and request.method != 'HEAD': + do_log = False + + # ─── 3) Pick the right cache ─── + if mime.startswith('audio/'): cache = cache_audio + elif mime.startswith('image/'): cache = cache_image + elif mime.startswith('video/'): cache = cache_video + else: cache = cache_other + + # ─── 4) Try to stream directly from diskcache ─── + try: + # returns a file-like whose .name is the real path on disk + with cache.read(subpath) as reader: + file_path = reader.name + filesize = os.path.getsize(file_path) + response = send_file( + file_path, + mimetype= mime, + conditional= True # enable Range / If-Modified / etc + ) + cached_hit = True + + except KeyError: + # cache miss → generate & write back to cache + cached_hit = False + + if mime.startswith('image/'): + # ─── 4a) Image branch: thumbnail & cache the JPEG ─── try: with Image.open(full_path) as img: img.thumbnail((1920, 1920)) if img.mode in ("RGBA", "P"): img = img.convert("RGB") - output_format = 'JPEG' - output_mime = 'image/jpeg' - save_kwargs = {'quality': 85} - img_bytes_io = io.BytesIO() - img.save(img_bytes_io, format=output_format, **save_kwargs) - thumb_bytes = img_bytes_io.getvalue() - filesize = len(thumb_bytes) - cache.set(subpath, (thumb_bytes, output_mime)) - response = send_file(io.BytesIO(thumb_bytes), mimetype=output_mime, conditional=True) + thumb_io = io.BytesIO() + img.save(thumb_io, format='JPEG', quality=85) + thumb_io.seek(0) + + # write thumbnail into diskcache as a real file + cache.set(subpath, thumb_io, read=True) + + # now re-open from cache to get the on-disk path + with cache.read(subpath) as reader: + file_path = reader.name + filesize = os.path.getsize(file_path) + response = send_file( + file_path, + mimetype= 'image/jpeg', + conditional= True + ) + except Exception as e: app.logger.error(f"Image processing failed for {subpath}: {e}") abort(500) + else: - # Cache non-image files: read bytes and cache + # ─── 4b) Non-image branch: cache original file ─── try: - with open(full_path, 'rb') as f: - file_bytes = f.read() - cache.set(subpath, (file_bytes, mime)) - file_bytes_io = io.BytesIO(file_bytes) - filesize = len(file_bytes_io.getbuffer()) - response = send_file(file_bytes_io, mimetype=mime, conditional=True) + # store the real file on diskcache + cache.set(subpath, open(full_path, 'rb'), read=True) + + # read back to get its path + with cache.read(subpath) as reader: + file_path = reader.name + filesize = os.path.getsize(file_path) + response = send_file( + file_path, + mimetype= mime, + conditional= True + ) + except Exception as e: - app.logger.error(f"Failed to read file {subpath}: {e}") + app.logger.error(f"Failed to cache file {subpath}: {e}") abort(500) - # Set Cache-Control header (browser caching for 1 day) + # ─── 5) Common headers & logging ─── response.headers['Cache-Control'] = 'public, max-age=86400' - # special rules for audio files. - # HEAD request checks if the audio file is available. GET requests coming from the audi player itself and can be made multiple times. - # a HEAD request only for logging will be ignored because of rules before - if mime and mime.startswith('audio/mpeg') and request.method != 'HEAD': - logging = False - - if logging: - a.log_file_access(subpath, filesize, mime, ip_address, user_agent, session['device_id'], bool(cached)) + if do_log: + a.log_file_access( + subpath, filesize, mime, + ip_address, user_agent, + session['device_id'], cached_hit + ) return response + @app.route("/transcript/") @auth.require_secret def get_transcript(subpath): @@ -322,61 +360,6 @@ def get_transcript(subpath): content = f.read() return content, 200, {'Content-Type': 'text/markdown; charset=utf-8'} -@app.route("/crawl/") -@auth.require_secret -def crawl_and_cache(subpath): - """ - Crawls through a directory and caches each file. - For images, it creates a thumbnail (max 1200x1200) and caches the processed image. - For non-images, it simply reads and caches the file bytes. - """ - root, *relative_parts = subpath.split('/') - base_path = session['folders'][root] - full_path = os.path.join(base_path, *relative_parts) - - cached_files = [] # List to hold cached file relative paths - - # Walk through all subdirectories and files - for root, dirs, files in os.walk(full_path): - for filename in files: - full_path_file = os.path.join(root, filename) - - # Skip if this file is already in the cache - if cache.get(full_path_file): - continue - - # Determine the MIME type - mime, _ = mimetypes.guess_type(full_path) - mime = mime or 'application/octet-stream' - - # Process image files differently - if mime.startswith('image/'): - try: - with Image.open(full_path) as img: - # Create a thumbnail (max 1200x1200) - img.thumbnail((1200, 1200)) - img_bytes_io = io.BytesIO() - # Save processed image as PNG - img.save(img_bytes_io, format='PNG', quality=85) - img_bytes = img_bytes_io.getvalue() - # Cache the processed image bytes along with its mime type - cache.set(full_path_file, (img_bytes, mime)) - cached_files.append(full_path_file) - except Exception as e: - app.logger.error(f"Image processing failed for {full_path_file}: {e}") - else: - # Process non-image files - try: - with open(full_path_file, 'rb') as f: - file_bytes = f.read() - cache.set(full_path_file, (file_bytes, mime)) - cached_files.append(full_path_file) - except Exception as e: - app.logger.error(f"Failed to read file {full_path_file}: {e}") - - # Return the list of cached files as a JSON response - return json.dumps({"cached_files": cached_files}, indent=4), 200 - def query_recent_connections(): global clients_connected, background_thread_running background_thread_running = True @@ -445,9 +428,20 @@ def handle_request_initial_data(): @app.route('/') @auth.require_secret def index(path): - title_short = os.environ.get('TITLE_SHORT', 'Default Title') - title_long = os.environ.get('TITLE_LONG', 'Default Title') - return render_template("app.html", title_short=title_short, title_long=title_long) + title_short = app_config.get('TITLE_SHORT', 'Default Title') + title_long = app_config.get('TITLE_LONG' , 'Default Title') + header_color = app_config.get('header_color' , '#000') + header_text_color = app_config.get('header_text_color', '#fff') + background_color = app_config.get('background_color', '#fff') + main_text_color = app_config.get('main_text_color', '#000') + return render_template("app.html", + title_short=title_short, + title_long=title_long, + header_color=header_color, + header_text_color=header_text_color, + main_text_color=main_text_color, + background_color=background_color, + ) if __name__ == '__main__': socketio.run(app, debug=True, host='0.0.0.0') diff --git a/auth.py b/auth.py index 3fa9ee6..e3ba65c 100644 --- a/auth.py +++ b/auth.py @@ -9,6 +9,9 @@ import base64 folder_config = {} +with open("app_config.json", 'r') as file: + app_config = json.load(file) + def require_secret(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -79,8 +82,20 @@ def require_secret(f): session['device_id'] = os.urandom(32).hex() return f(*args, **kwargs) else: - return render_template('index.html') - + title_short = app_config.get('TITLE_SHORT', 'Default Title') + title_long = app_config.get('TITLE_LONG' , 'Default Title') + header_color = app_config.get('header_color' , '#000') + header_text_color = app_config.get('header_text_color', '#fff') + background_color = app_config.get('background_color', '#fff') + main_text_color = app_config.get('main_text_color', '#000') + return render_template('index.html', + title_short=title_short, + title_long=title_long, + header_color=header_color, + header_text_color=header_text_color, + main_text_color=main_text_color, + background_color=background_color, + ) return decorated_function @require_secret diff --git a/keygen.py b/keygen.py new file mode 100644 index 0000000..645162b --- /dev/null +++ b/keygen.py @@ -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) diff --git a/migrate_cache.py b/migrate_cache.py new file mode 100644 index 0000000..6f67065 --- /dev/null +++ b/migrate_cache.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +migrate_cache.py + +Migrate DiskCache caches from “old” in‐memory entries (bytes, mime) into +“new” on‐disk read=True entries so that Flask can send them via send_file(path). + +Usage: + python migrate_cache.py /path/to/filecache_audio \ + /path/to/filecache_image \ + /path/to/filecache_video \ + /path/to/filecache_other +""" + +import io +import sys +from diskcache import Cache + +def migrate_cache(cache_path): + """ + Walks every key in the cache at `cache_path`. If the value is a tuple of + (bytes, mime), re‐writes it via read=True so DiskCache stores it as a file. + """ + print(f"➡ Migrating cache at {cache_path!r}") + cache = Cache(cache_path) + migrated = 0 + skipped = 0 + + # Iterate keys without loading everything into memory + for key in cache.iterkeys(): + try: + val = cache.get(key) + except Exception as e: + print(f" [ERROR] key={key!r} get failed: {e}") + continue + + # Detect old‐style entries: (bytes, mime) + if ( + isinstance(val, tuple) + and len(val) == 2 + and isinstance(val[0], (bytes, bytearray)) + and isinstance(val[1], str) + ): + data, mime = val + buf = io.BytesIO(data) + buf.seek(0) + # Re‐store as an on‐disk file + cache.set(key, buf, read=True) + migrated += 1 + else: + skipped += 1 + + cache.close() + print(f" → Done: migrated={migrated}, skipped={skipped}\n") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: migrate_cache.py [ ...]") + sys.exit(1) + + for directory in sys.argv[1:]: + migrate_cache(directory) diff --git a/static/app.css b/static/app.css index c1b7c09..b946d9b 100644 --- a/static/app.css +++ b/static/app.css @@ -7,9 +7,7 @@ html, body { /* Global Styles */ body { font-family: 'Helvetica Neue', Arial, sans-serif; - background-color: #f4f7f9; padding-top: 70px; /* Adjust to your header height */ - color: #333; } /* Header styles */ .site-header { @@ -20,8 +18,6 @@ body { display: flex; align-items: center; padding: 10px 20px; - background-color: #34495e; /* Using the theme color */ - color: #eee; } .site-header img.logo { height: 50px; diff --git a/static/logo.png b/static/logo.png deleted file mode 100644 index d2fcd45..0000000 Binary files a/static/logo.png and /dev/null differ diff --git a/static/logoW.png b/static/logoW.png deleted file mode 100644 index 37385be..0000000 Binary files a/static/logoW.png and /dev/null differ diff --git a/static/manifest.json b/static/manifest.json index b7d8342..c1c7a3f 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -7,12 +7,12 @@ "theme_color": "#34495e", "icons": [ { - "src": "/static/icons/logo-192x192.png", + "src": "/icon/logo-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/static/icons/logo-512x512.png", + "src": "/icon/logo-512x512.png", "sizes": "512x512", "type": "image/png" } diff --git a/static/sw.js b/static/sw.js index cfb6a83..43407e0 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,10 +7,10 @@ const assets = [ '/static/gallery.js', '/static/audioplayer.css', '/static/audioplayer.js', - '/static/icons/logo-192x192.png', - '/static/icons/logo-512x512.png', - '/static/logo.png', - '/static/logoW.png' + '/icon/logo-192x192.png', + '/icon/logo-512x512.png', + '/custom_logo/logoB.png', + '/custom_logo/logoW.png' ]; self.addEventListener('install', e => { diff --git a/templates/app.html b/templates/app.html index 6e81a17..3181e86 100644 --- a/templates/app.html +++ b/templates/app.html @@ -3,23 +3,22 @@ - + - - + {{ title_short }} - - + + - + @@ -32,11 +31,21 @@ + diff --git a/templates/error.html b/templates/error.html index ad7a243..fa71daa 100644 --- a/templates/error.html +++ b/templates/error.html @@ -3,25 +3,33 @@ - + - - - + - Gottesdienste Speyer und Schwegenheim + {{ title_long }} +
-
Etwas ist schief gelaufen. Entweder ein Link ist veraltet oder die App ist gerade gestört.
Versuche den Link aus der Telegram-Gruppe erneut anklicken um dies zu beheben.
+
Etwas ist schief gelaufen. Entweder ein Link ist veraltet oder die App ist gerade gestört.
Versuche den Freigabelink erneut anzuklicken.
diff --git a/templates/index.html b/templates/index.html index ad8b387..39197d7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,25 +3,33 @@ - + - - - + - Gottesdienste Speyer und Schwegenheim + {{ title_long }} +
-
Du hast keine gültigen Links im Speicher.
Bitte den Link aus der Telegram-Gruppe erneut anklicken.
+
Du hast keine Links die noch gültig sind.
Bitte den Freigabelink erneut anklicken.
diff --git a/templates/search.html b/templates/search.html index f69db2c..701fdae 100644 --- a/templates/search.html +++ b/templates/search.html @@ -5,14 +5,14 @@ - + {{ title_short }} - + @@ -43,7 +43,7 @@