Compare commits

...

6 Commits

4 changed files with 193 additions and 225 deletions
+84 -99
View File
@@ -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,20 @@ 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"
}
SENSITIVE_AUDIT_FIELDS = ["email", "username", "full_name", "phone"]
# 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')
@@ -645,7 +660,7 @@ def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
return jsonify({'error': message}), 400
flash(message, 'error')
return redirect(request.referrer or url_for('home'))
return redirect(url_for('login'))
def _get_current_module(path):
"""Resolve the active UI module for navbar separation."""
@@ -657,7 +672,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'
@@ -715,7 +729,7 @@ def _append_audit_event_standalone(event_type, payload):
db=db,
event_type=event_type,
actor=session.get('username', 'system'),
payload=payload,
payload=str(payload),
request_ip=request.remote_addr,
source='web',
)
@@ -938,7 +952,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 +1055,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 +1078,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 +2146,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 +3444,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 +3613,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 +4422,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 +4453,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')
@@ -4727,7 +4738,7 @@ def get_items():
'has_more': pagination_requested and ((offset + count) < total_count)
})
except Exception as e:
return jsonify({'items': [], 'error': str(e)}), 500
return jsonify({'items': []}), 500
finally:
if client:
client.close()
@@ -4818,7 +4829,7 @@ def get_bookings():
return jsonify({'ok': True, 'bookings': result})
except Exception as e:
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
return jsonify({'ok': False, 'bookings': []}), 500
finally:
if client:
client.close()
@@ -4908,7 +4919,7 @@ def get_user_appointments():
return jsonify({'ok': True, 'bookings': result})
except Exception as e:
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
return jsonify({'ok': False, 'bookings': []}), 500
finally:
if client:
client.close()
@@ -5106,7 +5117,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 +5350,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 +6104,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')
@@ -7289,7 +7300,7 @@ def get_planned_bookings(item_id):
client.close()
return jsonify({'ok': True, 'bookings': bookings})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)}), 500
return jsonify({'ok': False}), 500
@app.route('/get_planned_bookings_public/<item_id>')
@@ -7315,7 +7326,7 @@ def get_planned_bookings_public(item_id):
client.close()
return jsonify({'ok': True, 'bookings': bookings})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)}), 500
return jsonify({'ok': False}), 500
@app.route('/check_availability')
@@ -7396,7 +7407,7 @@ def check_availability():
client.close()
return jsonify({'ok': True, 'available': len(conflicts) == 0, 'conflicts': conflicts})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)}), 500
return jsonify({'ok': False}), 500
# def create_qr_code(id):
@@ -7671,7 +7682,7 @@ def add_booking():
return jsonify({'success': True, 'booking_id': str(booking_id)})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
return jsonify({'success': False})
@app.route('/cancel_booking/<id>', methods=['POST'])
def cancel_booking(id):
@@ -8035,23 +8046,14 @@ def admin_audit_dashboard():
al.ensure_audit_indexes(db)
verify_result = al.verify_audit_chain(db)
audit_rows = list(
db['audit_log'].find(
{},
{
'chain_index': 1,
'event_type': 1,
'actor': 1,
'source': 1,
'ip': 1,
'timestamp': 1,
'created_at': 1,
'entry_hash': 1,
'prev_hash': 1,
'payload': 1,
}
).sort('chain_index', -1).limit(200)
)
audit_rows = list(db['audit_log'].find(...).sort('chain_index', -1).limit(200))
# DEC_START: Decrypt the sensitive fields for display
for row in audit_rows:
if "payload" in row:
# decrypt_document_fields acts in-place
decrypt_document_fields(row["payload"], SENSITIVE_AUDIT_FIELDS)
# DEC_END
return render_template(
'admin_audit.html',
@@ -8096,28 +8098,18 @@ def admin_audit_export_pdf_official():
])
)
audit_rows = list(
db['audit_log'].find(
{},
{
'chain_index': 1,
'event_type': 1,
'actor': 1,
'source': 1,
'ip': 1,
'timestamp': 1,
'created_at': 1,
'entry_hash': 1,
'prev_hash': 1,
'payload': 1,
}
).sort('chain_index', -1).limit(limit)
)
audit_rows = list(db['audit_log'].find(...).sort('chain_index', -1).limit(limit))
# DEC_START: Decrypt sensitive fields for the PDF report
for row in audit_rows:
if "payload" in row:
decrypt_document_fields(row["payload"], SENSITIVE_AUDIT_FIELDS)
# DEC_END
# Get school information from settings or use defaults
school_info = _get_school_info_for_export()
# Generate PDF
# Generate PDF with now-decrypted audit_rows
pdf_content = pdf_export.generate_audit_pdf(
verify_result=verify_result,
event_counts=event_counts,
@@ -8125,7 +8117,7 @@ def admin_audit_export_pdf_official():
export_type='official',
school_info=school_info
)
response = make_response(pdf_content)
response.headers['Content-Type'] = 'application/pdf'
response.headers['Content-Disposition'] = f'attachment; filename=audit-official-report-{datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")}.pdf'
@@ -8196,7 +8188,7 @@ def admin_image_cache_stats():
})
except Exception as e:
app.logger.error(f"Error getting cache stats: {str(e)}")
return jsonify({'ok': False, 'error': str(e)}), 500
return jsonify({'ok': False}), 500
@app.route('/admin/image_cache_cleanup', methods=['POST'])
@@ -8245,7 +8237,7 @@ def admin_image_cache_cleanup():
})
except Exception as e:
app.logger.error(f"Error during image cache cleanup: {str(e)}")
return jsonify({'ok': False, 'error': str(e)}), 500
return jsonify({'ok': False}), 500
"""-----------------------------------------------------------Borrowing Management Routes-------------------------------------------------------"""
@@ -9737,9 +9729,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 +9748,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 +9767,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 +9776,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 +9787,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 +9806,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 +9968,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 +10173,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 +10252,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 +11548,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>')
@@ -11661,7 +11646,7 @@ def cleanup_old_optimized_images(max_age_days=30):
}
except Exception as e:
app.logger.error(f"Error during optimized image cleanup: {str(e)}")
return {'deleted': 0, 'freed_mb': 0, 'error': str(e)}
return {'deleted': 0, 'freed_mb': 0}
@app.route('/log_mobile_issue', methods=['POST'])
@@ -11706,7 +11691,7 @@ def log_mobile_issue():
return jsonify({'success': True})
except Exception as e:
app.logger.error(f"Error logging mobile issue: {str(e)}")
return jsonify({'success': False, 'error': str(e)})
return jsonify({'success': False})
def delete_item_images(filenames):
"""
@@ -11848,7 +11833,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'
@@ -11858,7 +11843,7 @@ def subscribe_to_push():
except Exception as e:
app.logger.error(f'Error subscribing to push: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
return jsonify({'success': False}), 500
@app.route('/api/push/unsubscribe', methods=['POST'])
@@ -11885,7 +11870,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'
@@ -11895,7 +11880,7 @@ def unsubscribe_from_push():
except Exception as e:
app.logger.error(f'Error unsubscribing from push: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
return jsonify({'success': False}), 500
@app.route('/api/push/subscriptions', methods=['GET'])
@@ -11929,7 +11914,7 @@ def get_push_subscriptions():
except Exception as e:
app.logger.error(f'Error getting push subscriptions: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
return jsonify({'success': False}), 500
@app.route('/api/push/vapid-key', methods=['GET'])
@@ -11954,7 +11939,7 @@ def get_vapid_key():
except Exception as e:
app.logger.error(f'Error getting VAPID key: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
return jsonify({'success': False}), 500
@app.route('/api/push/test', methods=['POST'])
@@ -11993,4 +11978,4 @@ def test_push_notification():
except Exception as e:
app.logger.error(f'Error sending test push: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
return jsonify({'success': False}), 500
+36 -13
View File
@@ -10,6 +10,7 @@ import hashlib
import json
import random
import time
from Web.modules.inventarsystem.data_protection import encrypt_document_fields, decrypt_document_fields
from pymongo.errors import DuplicateKeyError
@@ -24,21 +25,34 @@ def _entry_hash(prev_hash, payload):
base = f"{prev_hash}|{_stable_json(payload)}"
return hashlib.sha256(base.encode("utf-8")).hexdigest()
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5):
def get_decrypted_audit_logs(db, query=None, decrypt_fields=None):
"""
Append an audit event to a tamper-evident chain.
Retrieve and decrypt audit logs for analysis.
Args:
db: MongoDB database handle.
event_type (str): Event category.
actor (str): User/system who performed the action.
payload (dict): Event details.
request_ip (str, optional): Request origin.
source (str): Source subsystem.
query (dict): MongoDB query filter.
decrypt_fields (list): Fields within the 'payload' that should be decrypted.
"""
logs = db["audit_log"]
cursor = logs.find(query or {}).sort("chain_index", 1)
results = []
for entry in cursor:
# Decrypt specific fields if provided
if decrypt_fields:
decrypt_document_fields(entry.get("payload", {}), decrypt_fields)
results.append(entry)
return results
Returns:
dict: Inserted audit entry.
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5, encrypt_fields=None):
"""
Append an audit event, optionally encrypting specific payload fields.
Args:
...
encrypt_fields (list, optional): List of keys in 'payload' to encrypt.
"""
logs = db["audit_log"]
attempts = 0
@@ -49,15 +63,24 @@ def append_audit_event(db, event_type, actor, payload, request_ip=None, source="
chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1
timestamp = datetime.datetime.utcnow()
# 1. Create the payload dictionary
event_payload = payload or {}
# 2. Encrypt sensitive fields in-place if requested
if encrypt_fields:
encrypt_document_fields(event_payload, encrypt_fields)
entry_payload = {
"event_type": event_type,
"actor": actor or "system",
"source": source,
"ip": request_ip or "",
"payload": payload or {},
"payload": event_payload,
"timestamp": timestamp.isoformat() + "Z",
}
# 3. Hash the payload (which now contains encrypted values)
entry_hash = _entry_hash(prev_hash, entry_payload)
entry = {
+2 -3
View File
@@ -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:
+71 -110
View File
@@ -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}')