Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e258e07ab | |||
| 96245bb06b | |||
| 245c3c19dd | |||
| d76c69d836 | |||
| 9527fc10cf |
+50
-48
@@ -79,6 +79,7 @@ from Web.modules.inventarsystem.data_protection import (
|
||||
decrypt_text,
|
||||
encrypt_document_fields,
|
||||
encrypt_soft_deleted_media_pack,
|
||||
encrypt_text,
|
||||
)
|
||||
from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
|
||||
|
||||
@@ -375,6 +376,18 @@ PERMISSION_ACTION_ENDPOINTS = {
|
||||
'logs': 'can_view_logs',
|
||||
}
|
||||
|
||||
ALLOWED_COVER_DOMAINS = {
|
||||
"books.google.com",
|
||||
"covers.openlibrary.org",
|
||||
"images-na.ssl-images-amazon.com",
|
||||
"m.media-amazon.com",
|
||||
"www.isbn.de",
|
||||
"covers.openlibrary.org",
|
||||
"openlibrary.org",
|
||||
"lobid.org",
|
||||
"www.googleapis.com"
|
||||
}
|
||||
|
||||
# Apply the configuration for general use throughout the app
|
||||
APP_VERSION = __version__
|
||||
RELEASE_STATE_FILE = os.path.join(os.path.dirname(BASE_DIR), '.release-version')
|
||||
@@ -657,7 +670,6 @@ def _get_current_module(path):
|
||||
last_module = session.get('last_module')
|
||||
if last_module and cfg.MODULES.is_enabled(last_module):
|
||||
return last_module
|
||||
|
||||
if cfg.MODULES.is_enabled('inventory'):
|
||||
return 'inventory'
|
||||
return 'library' if cfg.MODULES.is_enabled('library') else 'inventory'
|
||||
@@ -714,9 +726,9 @@ def _append_audit_event_standalone(event_type, payload):
|
||||
al.append_audit_event(
|
||||
db=db,
|
||||
event_type=event_type,
|
||||
actor=session.get('username', 'system'),
|
||||
payload=payload,
|
||||
request_ip=request.remote_addr,
|
||||
actor=encrypt_text(session.get('username', 'system')),
|
||||
payload=encrypt_text(str(payload)),
|
||||
request_ip=encrypt_text(request.remote_addr),
|
||||
source='web',
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -938,7 +950,7 @@ def _create_notification(db, *, audience, notif_type, title, message, target_use
|
||||
tag=f'notification-{notif_type}'
|
||||
)
|
||||
except Exception as e:
|
||||
app.logger.warning(f'Failed to send push notification to {target_user}: {e}')
|
||||
app.logger.warning(f'Failed to send push notification to {encrypt_text(target_user)}: {e}')
|
||||
elif audience == 'admin':
|
||||
_bump_notification_version('admin')
|
||||
# Send push notification to all admins
|
||||
@@ -1041,7 +1053,7 @@ def _get_cached_unread_status(username, is_admin=False):
|
||||
if cached:
|
||||
return json.loads(cached), version_tag
|
||||
except Exception as exc:
|
||||
app.logger.warning(f'Could not read notification status cache for {username}: {exc}')
|
||||
app.logger.warning(f'Could not read notification status cache for {encrypt_text(username)}: {exc}')
|
||||
return None, version_tag
|
||||
|
||||
now = time.time()
|
||||
@@ -1064,7 +1076,7 @@ def _set_cached_unread_status(username, is_admin, version_tag, payload):
|
||||
cache_client.setex(key, NOTIFICATION_STATUS_CACHE_TTL, json.dumps(payload, default=str))
|
||||
return
|
||||
except Exception as exc:
|
||||
app.logger.warning(f'Could not write notification status cache for {username}: {exc}')
|
||||
app.logger.warning(f'Could not write notification status cache for {encrypt_text(username)}: {exc}')
|
||||
|
||||
with _NOTIFICATION_CACHE_LOCK:
|
||||
_NOTIFICATION_LOCAL_CACHE[key] = {
|
||||
@@ -2132,7 +2144,7 @@ def _upload_student_cards_excel():
|
||||
created_total += 1
|
||||
except Exception as exc:
|
||||
app.logger.error(f'Error importing student cards from Excel: {exc}')
|
||||
flash(f'Fehler beim Import der Bibliotheksausweise: {exc}', 'error')
|
||||
flash(f'Fehler beim Import der Bibliotheksausweise', 'error')
|
||||
return redirect(url_for('student_cards_admin'))
|
||||
finally:
|
||||
client.close()
|
||||
@@ -3430,7 +3442,7 @@ def api_library_scan_action():
|
||||
'channel': 'library_scan',
|
||||
'item_id': item_id,
|
||||
'item_name': item_doc.get('Name', ''),
|
||||
'borrower': borrower_name,
|
||||
'borrower':borrower_name,
|
||||
'student_card_id': student_card_id,
|
||||
'borrow_duration_days': borrow_duration_days,
|
||||
}
|
||||
@@ -3599,7 +3611,6 @@ def api_item_detail(item_id):
|
||||
"""
|
||||
client.close()
|
||||
return detail_html, 200
|
||||
return detail_html, 200
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error fetching item detail: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -4409,19 +4420,17 @@ def login():
|
||||
ctx = get_tenant_context()
|
||||
current_tenant_id = ctx.tenant_id if ctx else None
|
||||
current_tenant_db = ctx.db_name if ctx else cfg.MONGODB_DB
|
||||
app.logger.info(f"Login attempt: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
|
||||
app.logger.info(f"Debug login context: headers={dict(request.headers)} tenant_config={ctx.config if ctx else None} remote_addr={request.remote_addr} host={request.host}")
|
||||
app.logger.info(f"Raw login payload: username={username!r} password={password!r}")
|
||||
app.logger.info(f"Login attempt: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
|
||||
app.logger.info(f"Active MongoDB config: uri={getattr(cfg, 'MONGODB_URI', None)!r} host={cfg.MONGODB_HOST!r} port={cfg.MONGODB_PORT!r} default_db={cfg.MONGODB_DB!r}")
|
||||
if not username or not password:
|
||||
app.logger.warning(f"Login blocked: missing credentials tenant={current_tenant_id or 'default'} host={request.host} ip={request.remote_addr}")
|
||||
app.logger.warning(f"Login blocked: missing credentials tenant={current_tenant_id or 'default'} host={request.host} ip={encrypt_text(request.remote_addr)}")
|
||||
flash('Bitte alle Felder ausfüllen', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
user = us.check_nm_pwd(username, password)
|
||||
|
||||
if user:
|
||||
app.logger.info(f"Login success: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
|
||||
app.logger.info(f"Login success: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
|
||||
session['username'] = username
|
||||
is_admin_user = bool(user.get('Admin', False))
|
||||
session['admin'] = is_admin_user
|
||||
@@ -4442,7 +4451,7 @@ def login():
|
||||
else:
|
||||
return redirect(url_for('home'))
|
||||
else:
|
||||
app.logger.warning(f"Login failed: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
|
||||
app.logger.warning(f"Login failed: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
|
||||
flash('Ungültige Anmeldedaten', 'error')
|
||||
get_flashed_messages()
|
||||
return render_template('login.html')
|
||||
@@ -5106,7 +5115,7 @@ def upload_item():
|
||||
|
||||
# Log mobile request for debugging
|
||||
if is_mobile:
|
||||
app.logger.info(f"Mobile upload from {request.headers.get('User-Agent', 'unknown')} by {username}")
|
||||
app.logger.info(f"Mobile upload from {request.headers.get('User-Agent', 'unknown')} by {encrypt_text(username)}")
|
||||
|
||||
try:
|
||||
# Strip whitespace from all text fields
|
||||
@@ -5339,7 +5348,7 @@ def upload_item():
|
||||
|
||||
# Create a structured log entry for upload session
|
||||
upload_session_id = str(uuid.uuid4())[:8]
|
||||
app.logger.info(f"Starting image upload session {upload_session_id} - Files: {len(images)}, User: {username}")
|
||||
app.logger.info(f"Starting image upload session {upload_session_id} - Files: {len(images)}, User: {encrypt_text(username)}")
|
||||
|
||||
# Ensure all required directories exist
|
||||
for directory in [app.config['UPLOAD_FOLDER']]:
|
||||
@@ -6093,7 +6102,7 @@ def duplicate_item():
|
||||
|
||||
# Log mobile duplication for debugging
|
||||
if is_mobile:
|
||||
app.logger.info(f"Mobile duplication from {request.headers.get('User-Agent', 'unknown')} by {username}")
|
||||
app.logger.info(f"Mobile duplication from {request.headers.get('User-Agent', 'unknown')} by {encrypt_text(username)}")
|
||||
|
||||
# Get original item ID
|
||||
original_item_id = request.form.get('original_item_id')
|
||||
@@ -9737,9 +9746,6 @@ def fetch_book_info(isbn):
|
||||
def download_book_cover():
|
||||
"""
|
||||
API endpoint to download and save a book cover image from URL
|
||||
|
||||
Returns:
|
||||
dict: Success status and filename or error message
|
||||
"""
|
||||
if 'username' not in session:
|
||||
return jsonify({"error": "Not authorized"}), 403
|
||||
@@ -9759,17 +9765,17 @@ def download_book_cover():
|
||||
if parsed_url.scheme != 'https' or not parsed_url.netloc:
|
||||
return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400
|
||||
|
||||
hostname = parsed_url.hostname or ''
|
||||
if not _is_public_host(hostname):
|
||||
return jsonify({"error": "Target host is not allowed"}), 400
|
||||
# 2. SSRF Protection: Strict Allowlist Check
|
||||
if parsed_url.netloc not in ALLOWED_COVER_DOMAINS:
|
||||
return jsonify({"error": "Target host is not an allowed book cover provider"}), 403
|
||||
|
||||
# Download the image
|
||||
# Download the image (allow_redirects=False prevents redirecting to internal IPs)
|
||||
response = requests.get(image_url, stream=True, timeout=10, allow_redirects=False)
|
||||
|
||||
if response.status_code != 200:
|
||||
return jsonify({"error": f"Failed to download image: Status {response.status_code}"}), 400
|
||||
|
||||
# Check content type to ensure it's an image of allowed format
|
||||
# Check content type
|
||||
content_type = response.headers.get('content-type', '')
|
||||
allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']
|
||||
|
||||
@@ -9778,6 +9784,7 @@ def download_book_cover():
|
||||
"error": f"Nicht unterstütztes Bildformat: {content_type}. Erlaubte Formate: JPG, JPEG, PNG, GIF"
|
||||
}), 400
|
||||
|
||||
# Check content length header
|
||||
content_length = response.headers.get('Content-Length')
|
||||
if content_length:
|
||||
try:
|
||||
@@ -9786,14 +9793,10 @@ def download_book_cover():
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Generate a fully unique filename using UUID
|
||||
import uuid
|
||||
import time
|
||||
|
||||
# Generate a fully unique filename
|
||||
unique_id = str(uuid.uuid4())
|
||||
timestamp = time.strftime("%Y%m%d%H%M%S")
|
||||
|
||||
# Use appropriate extension based on content type
|
||||
extension = '.jpg' # default
|
||||
if 'image/png' in content_type.lower():
|
||||
extension = '.png'
|
||||
@@ -9801,15 +9804,16 @@ def download_book_cover():
|
||||
extension = '.gif'
|
||||
|
||||
filename = f"book_cover_{unique_id}_{timestamp}{extension}"
|
||||
|
||||
# Save the image to uploads folder
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
|
||||
# Save image in chunks (prevents memory exhaustion and enforces size limits)
|
||||
with open(filepath, 'wb') as f:
|
||||
written = 0
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
written += len(chunk)
|
||||
if written > 5 * 1024 * 1024:
|
||||
# Clean up the partial file before aborting
|
||||
os.remove(filepath)
|
||||
return jsonify({"error": "Image is too large"}), 413
|
||||
f.write(chunk)
|
||||
|
||||
@@ -9819,9 +9823,12 @@ def download_book_cover():
|
||||
"message": "Image downloaded successfully"
|
||||
})
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Network error downloading book cover: {e}")
|
||||
return jsonify({"error": "Netzwerkfehler beim Herunterladen des Bildes."}), 500
|
||||
except Exception as e:
|
||||
print(f"Error downloading book cover: {e}")
|
||||
|
||||
# Fixed syntax here: Removed the injected HTML that was appended to this line
|
||||
return jsonify({"error": f"Failed to download image: {str(e)}"}), 500
|
||||
"""
|
||||
@app.route('/proxy_image')
|
||||
@@ -9978,11 +9985,6 @@ def my_borrowed_items():
|
||||
'Status': 'planned'
|
||||
}))
|
||||
|
||||
# DEBUG: Log the number of planned appointments found
|
||||
app.logger.info(f"Found {len(planned_ausleihungen)} planned appointments for user {username}")
|
||||
for appt in planned_ausleihungen:
|
||||
app.logger.info(f"Planned appointment: ID={str(appt['_id'])}, Item={str(appt.get('Item'))}, Start={appt.get('Start')}")
|
||||
|
||||
# Process items
|
||||
active_items = []
|
||||
planned_items = []
|
||||
@@ -10188,7 +10190,7 @@ def mark_all_notifications_read():
|
||||
if result.modified_count > 0:
|
||||
_bump_notification_version(f'user:{username}')
|
||||
except Exception as exc:
|
||||
app.logger.warning(f"Could not mark all notifications as read for {username}: {exc}")
|
||||
app.logger.warning(f"Could not mark all notifications as read for {encrypt_text(username)}: {exc}")
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
@@ -10267,7 +10269,7 @@ def notifications_unread_status():
|
||||
etag_value = _build_unread_status_etag(version_tag, payload)
|
||||
return _build_cached_json_response(payload, etag_value)
|
||||
except Exception as exc:
|
||||
app.logger.warning(f"Could not fetch unread notification status for {username}: {exc}")
|
||||
app.logger.warning(f"Could not fetch unread notification status for {encrypt_text(username)}: {exc}")
|
||||
return jsonify({'ok': False, 'error': 'status_fetch_failed'}), 500
|
||||
finally:
|
||||
if client:
|
||||
@@ -11563,10 +11565,10 @@ def log_mobile_action(action, request, success=True, details=None):
|
||||
if details:
|
||||
message += f" - Details: {details}"
|
||||
|
||||
if success:
|
||||
app.logger.info(message)
|
||||
else:
|
||||
app.logger.error(message)
|
||||
# if success:
|
||||
# app.logger.info(message)
|
||||
# else:
|
||||
# app.logger.error(message)
|
||||
|
||||
# Add explicit static file routes to handle CSS serving issues
|
||||
@app.route('/static/<path:filename>')
|
||||
@@ -11848,7 +11850,7 @@ def subscribe_to_push():
|
||||
success = pn.save_push_subscription(username, subscription)
|
||||
|
||||
if success:
|
||||
app.logger.info(f'Push subscription saved for {username}')
|
||||
app.logger.info(f'Push subscription saved for {encrypt_text(username)}')
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Successfully subscribed to push notifications'
|
||||
@@ -11885,7 +11887,7 @@ def unsubscribe_from_push():
|
||||
success = pn.remove_push_subscription(username, endpoint)
|
||||
|
||||
if success:
|
||||
app.logger.info(f'Push subscription removed for {username}')
|
||||
app.logger.info(f'Push subscription removed for {encrypt_text(username)}')
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Successfully unsubscribed from push notifications'
|
||||
|
||||
@@ -21,7 +21,7 @@ def log_status_change(ausleihung_id, old_status, new_status, user=None):
|
||||
ausleihung_id: Die ID der Ausleihung
|
||||
old_status: Der alte Status
|
||||
new_status: Der neue Status
|
||||
user: Der Benutzer, der die Änderung vorgenommen hat (optional)
|
||||
|
||||
"""
|
||||
try:
|
||||
# Erstelle Log-Verzeichnis, falls es nicht existiert
|
||||
@@ -34,10 +34,9 @@ def log_status_change(ausleihung_id, old_status, new_status, user=None):
|
||||
|
||||
# Protokolliere die Änderung
|
||||
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
user_info = f" by {user}" if user else ""
|
||||
|
||||
with open(log_file, 'a', encoding='utf-8') as f:
|
||||
f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'{user_info}\n")
|
||||
f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'\n")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -215,7 +215,8 @@ def client(appointment_id):
|
||||
current_user=session.get('username', ''),
|
||||
tenant_id=_current_tenant_id(),
|
||||
can_view_booking_names=can_view_booking_names,
|
||||
custom_fields=custom_fields
|
||||
custom_fields=custom_fields,
|
||||
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||
)
|
||||
|
||||
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
|
||||
@@ -239,7 +240,8 @@ def client(appointment_id):
|
||||
tenant_id=_current_tenant_id(),
|
||||
can_view_booking_names=can_view_booking_names,
|
||||
custom_fields=custom_fields,
|
||||
appointment_item=appointment_item
|
||||
appointment_item=appointment_item,
|
||||
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||
)
|
||||
|
||||
|
||||
@@ -356,6 +358,7 @@ def configure():
|
||||
add_to_calendar=add_to_calendar,
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
title=title,
|
||||
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||
)
|
||||
|
||||
return render_template(
|
||||
@@ -366,6 +369,7 @@ def configure():
|
||||
add_to_calendar=False,
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
title=None,
|
||||
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||
)
|
||||
|
||||
|
||||
@@ -553,4 +557,5 @@ def main():
|
||||
current_user=current_user,
|
||||
upcoming_events=upcoming_events,
|
||||
tenant_id=tenant_id,
|
||||
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||
)
|
||||
+71
-110
@@ -13,6 +13,7 @@ import logging
|
||||
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
from Web.modules.inventarsystem.data_protection import encrypt_text, decrypt_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,16 +45,22 @@ if not VAPID_PUBLIC_KEY or not VAPID_PRIVATE_KEY:
|
||||
serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
|
||||
VAPID_PUBLIC_KEY = b64urlencode(raw_pub)
|
||||
VAPID_PUBLIC_KEY = b64urlencode(raw_pub).decode('utf-8')
|
||||
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM
|
||||
except Exception as e:
|
||||
logger.error(f'Could not load or generate VAPID keys: {e}')
|
||||
|
||||
# Push service endpoint (typically Firebase or Web Push Service)
|
||||
PUSH_SERVICE_URL = 'https://fcm.googleapis.com/fcm/send' # Firebase Cloud Messaging
|
||||
FCM_API_KEY = os.getenv('FCM_API_KEY', '') # Firebase API key
|
||||
|
||||
|
||||
def _get_username_hash(username):
|
||||
"""Generates a deterministic hash for database lookups."""
|
||||
if not username:
|
||||
return None
|
||||
return hashlib.sha256(username.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def get_push_subscriptions_collection(db=None):
|
||||
"""Get MongoDB push subscriptions collection"""
|
||||
if db is None:
|
||||
@@ -64,71 +71,68 @@ def get_push_subscriptions_collection(db=None):
|
||||
|
||||
def get_user_subscriptions(username):
|
||||
"""
|
||||
Get all active push subscriptions for a user
|
||||
|
||||
Args:
|
||||
username (str): Username
|
||||
|
||||
Returns:
|
||||
list: List of subscription documents
|
||||
Get all active push subscriptions for a user, decrypting data on the fly.
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
subs_col = get_push_subscriptions_collection(db)
|
||||
|
||||
subscriptions = list(subs_col.find({
|
||||
'Username': username,
|
||||
# Query using the deterministic hash, NOT the encrypted text directly
|
||||
user_hash = _get_username_hash(username)
|
||||
|
||||
encrypted_subscriptions = list(subs_col.find({
|
||||
'UsernameHash': user_hash,
|
||||
'IsActive': True
|
||||
}))
|
||||
|
||||
client.close()
|
||||
return subscriptions
|
||||
|
||||
# Decrypt endpoints and keys before returning
|
||||
decrypted_subs = []
|
||||
for sub in encrypted_subscriptions:
|
||||
try:
|
||||
sub['Endpoint'] = decrypt_text(sub.get('Endpoint'))
|
||||
|
||||
# Keys are stored as encrypted JSON strings
|
||||
decrypted_keys_str = decrypt_text(sub.get('Keys'))
|
||||
sub['Keys'] = json.loads(decrypted_keys_str) if decrypted_keys_str else {}
|
||||
|
||||
decrypted_subs.append(sub)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt subscription payload for hash {user_hash}: {e}")
|
||||
|
||||
return decrypted_subs
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting push subscriptions for {username}: {e}')
|
||||
logger.error(f'Error getting push subscriptions for user: {e}')
|
||||
return []
|
||||
|
||||
|
||||
def save_push_subscription(username, subscription_obj):
|
||||
"""
|
||||
Save a new push subscription for a user
|
||||
|
||||
Args:
|
||||
username (str): Username
|
||||
subscription_obj (dict): Subscription object from Service Worker
|
||||
{
|
||||
'endpoint': 'https://...',
|
||||
'keys': {
|
||||
'p256dh': '...',
|
||||
'auth': '...'
|
||||
}
|
||||
}
|
||||
|
||||
Returns:
|
||||
bool: Success status
|
||||
Save a new push subscription for a user with field-level encryption.
|
||||
"""
|
||||
try:
|
||||
if not subscription_obj.get('endpoint'):
|
||||
logger.warning(f'Invalid subscription object for {username}')
|
||||
endpoint = subscription_obj.get('endpoint')
|
||||
if not endpoint:
|
||||
logger.warning('Invalid subscription object: missing endpoint')
|
||||
return False
|
||||
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
subs_col = get_push_subscriptions_collection(db)
|
||||
|
||||
# Create unique hash of subscription to avoid duplicates
|
||||
sub_hash = hashlib.md5(
|
||||
f"{username}:{subscription_obj['endpoint']}".encode()
|
||||
# Create unique hash of subscription using plaintext data to avoid duplicates
|
||||
sub_hash = hashlib.shake_256(
|
||||
f"{username}:{endpoint}".encode('utf-8')
|
||||
).hexdigest()
|
||||
|
||||
# Check if subscription already exists
|
||||
# Check if subscription already exists by Hash
|
||||
existing = subs_col.find_one({
|
||||
'Username': username,
|
||||
'SubscriptionHash': sub_hash
|
||||
})
|
||||
|
||||
if existing:
|
||||
# Update last used time
|
||||
subs_col.update_one(
|
||||
{'_id': existing['_id']},
|
||||
{'$set': {
|
||||
@@ -136,15 +140,19 @@ def save_push_subscription(username, subscription_obj):
|
||||
'IsActive': True
|
||||
}}
|
||||
)
|
||||
logger.info(f'Updated existing subscription for {username}')
|
||||
logger.info('Updated existing push subscription')
|
||||
client.close()
|
||||
return True
|
||||
|
||||
# Save new subscription
|
||||
# Format keys as JSON string for your encrypt_text module
|
||||
keys_str = json.dumps(subscription_obj.get('keys', {}))
|
||||
|
||||
# Save new subscription, encrypting sensitive fields
|
||||
subscription_doc = {
|
||||
'Username': username,
|
||||
'Endpoint': subscription_obj['endpoint'],
|
||||
'Keys': subscription_obj.get('keys', {}),
|
||||
'UsernameHash': _get_username_hash(username),
|
||||
'Username': encrypt_text(username),
|
||||
'Endpoint': encrypt_text(endpoint),
|
||||
'Keys': encrypt_text(keys_str),
|
||||
'SubscriptionHash': sub_hash,
|
||||
'IsActive': True,
|
||||
'CreatedAt': datetime.datetime.now(),
|
||||
@@ -153,36 +161,31 @@ def save_push_subscription(username, subscription_obj):
|
||||
}
|
||||
|
||||
subs_col.insert_one(subscription_doc)
|
||||
logger.info(f'Saved new push subscription for {username}')
|
||||
logger.info('Saved new encrypted push subscription')
|
||||
client.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving push subscription for {username}: {e}')
|
||||
logger.error(f'Error saving push subscription: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def remove_push_subscription(username, endpoint):
|
||||
"""
|
||||
Remove a push subscription
|
||||
|
||||
Args:
|
||||
username (str): Username
|
||||
endpoint (str): Subscription endpoint URL
|
||||
|
||||
Returns:
|
||||
bool: Success status
|
||||
Remove a push subscription by making it inactive.
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
subs_col = get_push_subscriptions_collection(db)
|
||||
|
||||
# Recreate the deterministic hash to find the specific subscription
|
||||
sub_hash = hashlib.shake_256(
|
||||
f"{username}:{endpoint}".encode('utf-8')
|
||||
).hexdigest()
|
||||
|
||||
result = subs_col.update_one(
|
||||
{
|
||||
'Username': username,
|
||||
'Endpoint': endpoint
|
||||
},
|
||||
{'SubscriptionHash': sub_hash},
|
||||
{'$set': {'IsActive': False}}
|
||||
)
|
||||
|
||||
@@ -190,31 +193,19 @@ def remove_push_subscription(username, endpoint):
|
||||
return result.modified_count > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error removing push subscription for {username}: {e}')
|
||||
logger.error(f'Error removing push subscription: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def send_push_notification(username, title, body, icon=None, url='/', reference=None, tag='notification'):
|
||||
"""
|
||||
Send a push notification to all user's subscriptions
|
||||
|
||||
Args:
|
||||
username (str): Target username
|
||||
title (str): Notification title
|
||||
body (str): Notification body
|
||||
icon (str, optional): Icon URL
|
||||
url (str, optional): URL to open on click
|
||||
reference (dict, optional): Reference data (item_id, etc)
|
||||
tag (str, optional): Notification tag for grouping
|
||||
|
||||
Returns:
|
||||
int: Number of successfully sent notifications
|
||||
Send a push notification to all user's subscriptions.
|
||||
"""
|
||||
try:
|
||||
subscriptions = get_user_subscriptions(username)
|
||||
|
||||
if not subscriptions:
|
||||
logger.debug(f'No active push subscriptions for {username}')
|
||||
logger.debug('No active push subscriptions for user')
|
||||
return 0
|
||||
|
||||
sent_count = 0
|
||||
@@ -232,19 +223,18 @@ def send_push_notification(username, title, body, icon=None, url='/', reference=
|
||||
if success:
|
||||
sent_count += 1
|
||||
else:
|
||||
# Mark subscription as inactive if send fails
|
||||
_mark_subscription_inactive(subscription['_id'])
|
||||
|
||||
logger.info(f'Sent push notification to {username}: {sent_count}/{len(subscriptions)} subscriptions')
|
||||
logger.info(f'Sent push notification: {sent_count}/{len(subscriptions)} subscriptions')
|
||||
return sent_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error sending push notification to {username}: {e}')
|
||||
logger.error(f'Error sending push notification: {e}')
|
||||
return 0
|
||||
|
||||
|
||||
def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
|
||||
"""Send push notification to a specific subscription"""
|
||||
"""Send push notification to a specific decrypted subscription"""
|
||||
try:
|
||||
payload = {
|
||||
'title': title,
|
||||
@@ -256,20 +246,17 @@ def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
|
||||
'reference': reference or {},
|
||||
}
|
||||
|
||||
# If using Firebase Cloud Messaging
|
||||
if FCM_API_KEY and subscription.get('Endpoint', '').startswith('https://fcm.'):
|
||||
return _send_fcm_notification(subscription, payload)
|
||||
|
||||
# Otherwise use standard Web Push Protocol
|
||||
return _send_web_push_notification(subscription, payload)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error sending to subscription {subscription.get("_id")}: {e}')
|
||||
logger.error(f'Error sending to subscription: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def _send_fcm_notification(subscription, payload):
|
||||
"""Send notification via Firebase Cloud Messaging"""
|
||||
try:
|
||||
if not FCM_API_KEY:
|
||||
logger.warning('FCM_API_KEY not configured')
|
||||
@@ -311,9 +298,7 @@ def _send_fcm_notification(subscription, payload):
|
||||
|
||||
|
||||
def _send_web_push_notification(subscription, payload):
|
||||
"""Send notification using standard Web Push Protocol"""
|
||||
try:
|
||||
# This requires pywebpush library
|
||||
from pywebpush import webpush
|
||||
|
||||
webpush(
|
||||
@@ -325,13 +310,13 @@ def _send_web_push_notification(subscription, payload):
|
||||
vapid_private_key=VAPID_PRIVATE_KEY,
|
||||
vapid_claims={'sub': VAPID_SUBJECT},
|
||||
timeout=10,
|
||||
ttl=3600 # Notification expires after 1 hour if device is offline
|
||||
ttl=3600
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
logger.warning('pywebpush not installed, install with: pip install pywebpush')
|
||||
logger.warning('pywebpush not installed. pip install pywebpush')
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f'Web push error: {e}')
|
||||
@@ -339,7 +324,6 @@ def _send_web_push_notification(subscription, payload):
|
||||
|
||||
|
||||
def _mark_subscription_inactive(subscription_id):
|
||||
"""Mark a subscription as inactive (e.g., after failed send)"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
@@ -349,37 +333,21 @@ def _mark_subscription_inactive(subscription_id):
|
||||
{'_id': ObjectId(subscription_id)},
|
||||
{'$set': {'IsActive': False}}
|
||||
)
|
||||
|
||||
client.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Error marking subscription inactive: {e}')
|
||||
|
||||
|
||||
def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
|
||||
"""
|
||||
Send a push notification to all admin users
|
||||
|
||||
Args:
|
||||
title (str): Notification title
|
||||
body (str): Notification body
|
||||
icon (str, optional): Icon URL
|
||||
url (str, optional): URL to open on click
|
||||
reference (dict, optional): Reference data
|
||||
|
||||
Returns:
|
||||
int: Total notifications sent
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
users_col = db['users']
|
||||
|
||||
# Get all admin users
|
||||
admin_users = list(users_col.find(
|
||||
{'Admin': True},
|
||||
{'Username': 1}
|
||||
))
|
||||
|
||||
client.close()
|
||||
|
||||
total_sent = 0
|
||||
@@ -404,10 +372,6 @@ def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
|
||||
|
||||
|
||||
def cleanup_inactive_subscriptions():
|
||||
"""
|
||||
Remove inactive subscriptions older than 30 days
|
||||
Run this periodically as a maintenance task
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
@@ -429,22 +393,19 @@ def cleanup_inactive_subscriptions():
|
||||
return 0
|
||||
|
||||
|
||||
# Database collection schema
|
||||
def ensure_push_subscriptions_collection():
|
||||
"""Ensure the push_subscriptions collection exists with proper indexes"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
subs_col = get_push_subscriptions_collection(db)
|
||||
|
||||
# Create indexes
|
||||
subs_col.create_index('Username')
|
||||
subs_col.create_index([('Username', 1), ('IsActive', 1)])
|
||||
subs_col.create_index([('CreatedAt', 1)]) # TTL-like usage
|
||||
subs_col.create_index('UsernameHash')
|
||||
subs_col.create_index([('UsernameHash', 1), ('IsActive', 1)])
|
||||
subs_col.create_index([('CreatedAt', 1)])
|
||||
subs_col.create_index('SubscriptionHash', unique=True)
|
||||
|
||||
logger.info('Push subscriptions collection indexes created')
|
||||
client.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error ensuring push subscriptions collection: {e}')
|
||||
logger.error(f'Error ensuring push subscriptions collection: {e}')
|
||||
@@ -1144,6 +1144,17 @@
|
||||
<li class="nav-item dropdown ms-lg-auto">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
|
||||
{% if 'username' in session %}
|
||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
|
||||
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('admin_audit_dashboard', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
|
||||
{% if current_permissions.pages.get('home', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li>
|
||||
{% endif %}
|
||||
@@ -1155,6 +1166,42 @@
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex">
|
||||
{% if 'username' in session %}
|
||||
<div class="function-search-wrap">
|
||||
<form class="function-search-form" data-function-search="true">
|
||||
<input
|
||||
class="function-search-input"
|
||||
type="search"
|
||||
name="function_search"
|
||||
placeholder="Funktion suchen..."
|
||||
list="function-search-options"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button class="function-search-btn" type="submit">Los</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if current_tenant_db %}
|
||||
<span class="navbar-text tenant-badge" title="Aktive Tenant-Datenbank">{{ current_tenant_db }}</span>
|
||||
{% endif %}
|
||||
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
|
||||
<div class="dropdown me-2 user-menu-wrap">
|
||||
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
|
||||
👤
|
||||
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
|
||||
{% if current_permissions.pages.get('notifications_view', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
Reference in New Issue
Block a user