From c06fd044a141578d98651995aa248129fdf5a8b5 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:23:43 -0700 Subject: [PATCH] Profile Permissions & Page Access Control --- database/music_database.py | 78 +++++++-- web_server.py | 100 ++++++++++- webui/index.html | 29 ++++ webui/static/script.js | 342 +++++++++++++++++++++++++++++++++++-- webui/static/style.css | 80 +++++++++ 5 files changed, 594 insertions(+), 35 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 91af9c53..080b8258 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -340,6 +340,7 @@ class MusicDatabase: self._add_profile_support_v2(cursor) self._add_profile_support_v3(cursor) self._add_profile_support_v4(cursor) + self._add_profile_settings(cursor) # Mirrored playlists — persistent backup of parsed playlists from any service cursor.execute(""" @@ -2101,6 +2102,34 @@ class MusicDatabase: except Exception as e: logger.error(f"Error in profile support v4 migration: {e}") + def _add_profile_settings(self, cursor): + """Add home_page, allowed_pages, can_download columns to profiles table""" + try: + cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_migration_settings' LIMIT 1") + if cursor.fetchone(): + return # Already migrated + + logger.info("Applying profile settings migration...") + + for col_sql in [ + "ALTER TABLE profiles ADD COLUMN home_page TEXT DEFAULT NULL", + "ALTER TABLE profiles ADD COLUMN allowed_pages TEXT DEFAULT NULL", + "ALTER TABLE profiles ADD COLUMN can_download INTEGER DEFAULT 1", + ]: + try: + cursor.execute(col_sql) + except sqlite3.OperationalError: + pass # Column already exists + + cursor.execute(""" + INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_migration_settings', '1') + """) + + logger.info("Profile settings migration completed successfully") + + except Exception as e: + logger.error(f"Error in profile settings migration: {e}") + # ── Profile CRUD ────────────────────────────────────────────────── def get_all_profiles(self) -> List[Dict[str, Any]]: @@ -2114,16 +2143,23 @@ class MusicDatabase: cursor.execute("SELECT * FROM profiles ORDER BY id") rows = cursor.fetchall() columns = [desc[0] for desc in cursor.description] - return [{ - 'id': row['id'], - 'name': row['name'], - 'avatar_color': row['avatar_color'], - 'avatar_url': row['avatar_url'] if 'avatar_url' in columns else None, - 'is_admin': bool(row['is_admin']), - 'has_pin': row['pin_hash'] is not None, - 'created_at': row['created_at'], - 'updated_at': row['updated_at'], - } for row in rows] + results = [] + for row in rows: + ap_raw = row['allowed_pages'] if 'allowed_pages' in columns else None + results.append({ + 'id': row['id'], + 'name': row['name'], + 'avatar_color': row['avatar_color'], + 'avatar_url': row['avatar_url'] if 'avatar_url' in columns else None, + 'is_admin': bool(row['is_admin']), + 'has_pin': row['pin_hash'] is not None, + 'home_page': row['home_page'] if 'home_page' in columns else None, + 'allowed_pages': json.loads(ap_raw) if ap_raw else None, + 'can_download': bool(row['can_download']) if 'can_download' in columns else True, + 'created_at': row['created_at'], + 'updated_at': row['updated_at'], + }) + return results except Exception as e: logger.error(f"Error getting profiles: {e}") return [{'id': 1, 'name': 'Admin', 'avatar_color': '#6366f1', 'avatar_url': None, 'is_admin': True, 'has_pin': False}] @@ -2137,6 +2173,7 @@ class MusicDatabase: row = cursor.fetchone() if row: columns = [desc[0] for desc in cursor.description] + ap_raw = row['allowed_pages'] if 'allowed_pages' in columns else None return { 'id': row['id'], 'name': row['name'], @@ -2144,6 +2181,9 @@ class MusicDatabase: 'avatar_url': row['avatar_url'] if 'avatar_url' in columns else None, 'is_admin': bool(row['is_admin']), 'has_pin': row['pin_hash'] is not None, + 'home_page': row['home_page'] if 'home_page' in columns else None, + 'allowed_pages': json.loads(ap_raw) if ap_raw else None, + 'can_download': bool(row['can_download']) if 'can_download' in columns else True, 'created_at': row['created_at'], 'updated_at': row['updated_at'], } @@ -2154,15 +2194,17 @@ class MusicDatabase: def create_profile(self, name: str, avatar_color: str = '#6366f1', pin_hash: Optional[str] = None, is_admin: bool = False, - avatar_url: Optional[str] = None) -> Optional[int]: + avatar_url: Optional[str] = None, home_page: Optional[str] = None, + allowed_pages: Optional[list] = None, can_download: bool = True) -> Optional[int]: """Create a new profile. Returns new profile ID or None on error.""" try: with self._get_connection() as conn: cursor = conn.cursor() + ap_json = json.dumps(allowed_pages) if allowed_pages is not None else None cursor.execute(""" - INSERT INTO profiles (name, avatar_color, pin_hash, is_admin, avatar_url) - VALUES (?, ?, ?, ?, ?) - """, (name, avatar_color, pin_hash, int(is_admin), avatar_url)) + INSERT INTO profiles (name, avatar_color, pin_hash, is_admin, avatar_url, home_page, allowed_pages, can_download) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (name, avatar_color, pin_hash, int(is_admin), avatar_url, home_page, ap_json, int(can_download))) conn.commit() return cursor.lastrowid except sqlite3.IntegrityError: @@ -2173,9 +2215,13 @@ class MusicDatabase: return None def update_profile(self, profile_id: int, **kwargs) -> bool: - """Update profile fields. Accepts: name, avatar_color, avatar_url, pin_hash, is_admin.""" - allowed = {'name', 'avatar_color', 'avatar_url', 'pin_hash', 'is_admin'} + """Update profile fields. Accepts: name, avatar_color, avatar_url, pin_hash, is_admin, home_page, allowed_pages, can_download.""" + allowed = {'name', 'avatar_color', 'avatar_url', 'pin_hash', 'is_admin', 'home_page', 'allowed_pages', 'can_download'} updates = {k: v for k, v in kwargs.items() if k in allowed} + # Serialize allowed_pages list to JSON string for storage + if 'allowed_pages' in updates: + v = updates['allowed_pages'] + updates['allowed_pages'] = json.dumps(v) if v is not None else None if not updates: return False try: diff --git a/web_server.py b/web_server.py index c7df3a2a..506d50e7 100644 --- a/web_server.py +++ b/web_server.py @@ -197,6 +197,22 @@ def get_current_profile_id() -> int: except AttributeError: return 1 +# Valid page IDs for profile permission validation +VALID_PAGE_IDS = {'dashboard', 'sync', 'downloads', 'discover', 'artists', 'automations', 'library', 'import', 'settings', 'help'} + +def check_download_permission(): + """Check if current profile has download permission. Returns error response or None if allowed.""" + pid = get_current_profile_id() + if pid == 1: + return None # Root admin always allowed + try: + profile = get_database().get_profile(pid) + if profile and not profile.get('can_download', True): + return jsonify({'success': False, 'error': 'Downloads are disabled for this profile.'}), 403 + except Exception: + pass # DB error — don't block + return None + # --- Docker Helper Functions --- def docker_resolve_path(path_str): """ @@ -6465,6 +6481,9 @@ def stream_enhanced_search_track(): @app.route('/api/download', methods=['POST']) def start_download(): """Simple download route""" + dl_err = check_download_permission() + if dl_err: + return dl_err data = request.get_json() if not data: return jsonify({"error": "No download data provided."}), 400 @@ -7096,6 +7115,9 @@ def get_task_candidates(task_id): @app.route('/api/downloads/task//download-candidate', methods=['POST']) def download_selected_candidate(task_id): """Restart a not_found/failed task by downloading a user-selected candidate.""" + dl_err = check_download_permission() + if dl_err: + return dl_err try: data = request.get_json() if not data or not data.get('username') or not data.get('filename'): @@ -10806,6 +10828,9 @@ def start_matched_download(): 2. Regular album downloads (fallback) 3. Single track downloads """ + dl_err = check_download_permission() + if dl_err: + return dl_err try: data = request.get_json() download_payload = data.get('search_result', {}) @@ -14982,6 +15007,10 @@ def get_version_info(): "title": "🔧 Recent Updates", "description": "Latest fixes and improvements", "features": [ + "• Profile Permissions — admin can control which pages each user can access and disable download functionality per profile", + "• Per-user home page — every user can choose their own landing page; non-admin defaults to Discover", + "• Enhanced Library Manager restricted to admin profiles only (management tool with inline editing)", + "• Download buttons hidden globally for profiles with downloads disabled, enforced on both frontend and backend", "• Genius search fix — no longer blindly matches wrong artists (e.g. '50 Cent' → '108'); all bad matches auto-reset on first launch", "• Library page fix — albums no longer merge across different artists with the same album title/year", "• Post-processing race condition fix — no more MutagenError on files already moved by another thread", @@ -15134,13 +15163,18 @@ def get_version_info(): }, { "title": "👥 Multi-Profile Support", - "description": "Netflix-style profile picker for shared households", + "description": "Netflix-style profile picker for shared households with per-profile permissions", "features": [ "• Multiple profiles sharing one SoulSync instance with isolated personal data", "• Each profile gets its own watchlist, wishlist, discovery pool, and similar artists", "• Optional PIN protection per profile with admin-only management", "• Profile avatar images via URL with colored-initial fallback", "• Shared music library and service credentials across all profiles", + "• Admin-controlled page access — choose which sidebar pages each profile can see", + "• Per-profile download toggle — disable downloading for specific users (frontend + backend enforced)", + "• Customizable home page — each user picks their own landing page from their permitted pages", + "• Non-admin users default to Discover page instead of Dashboard", + "• Self-service profile editing — non-admin users can change their name and home page", "• Zero-downtime migration — existing data maps to auto-created admin profile", "• Single-user installs see no changes until a second profile is created" ] @@ -16271,6 +16305,9 @@ def start_wishlist_missing_downloads(): This endpoint fetches wishlist tracks and manages them with batch processing identical to playlist processing, maintaining exactly 3 concurrent downloads. """ + dl_err = check_download_permission() + if dl_err: + return dl_err try: # Check if auto-processing is currently running (prevent concurrent wishlist access) if is_wishlist_actually_processing(): @@ -20092,6 +20129,9 @@ def start_playlist_missing_downloads(playlist_id): This endpoint receives the list of missing tracks and manages them with batch processing like the GUI, maintaining exactly 3 concurrent downloads. """ + dl_err = check_download_permission() + if dl_err: + return dl_err data = request.get_json() missing_tracks = data.get('missing_tracks', []) if not missing_tracks: @@ -21334,9 +21374,12 @@ def start_missing_tracks_process(playlist_id): @app.route('/api/tracks/download_missing', methods=['POST']) def start_missing_downloads(): """Legacy endpoint - redirect to new playlist-based endpoint""" + dl_err = check_download_permission() + if dl_err: + return dl_err data = request.get_json() missing_tracks = data.get('missing_tracks', []) - + if not missing_tracks: return jsonify({"success": False, "error": "No missing tracks provided"}), 400 @@ -25555,7 +25598,27 @@ def create_profile(): from werkzeug.security import generate_password_hash pin_hash = generate_password_hash(pin, method='pbkdf2:sha256') - profile_id = database.create_profile(name, avatar_color, pin_hash, is_admin=False, avatar_url=avatar_url) + # Profile settings: home_page, allowed_pages, can_download + home_page = data.get('home_page') or None + allowed_pages = data.get('allowed_pages') # list or None + can_download = data.get('can_download', True) + + # Validate page IDs + if home_page and home_page not in VALID_PAGE_IDS: + home_page = None + if allowed_pages is not None: + allowed_pages = [p for p in allowed_pages if p in VALID_PAGE_IDS] + # Non-admin should never have 'settings' in allowed_pages + if 'settings' in allowed_pages: + allowed_pages.remove('settings') + # If home_page not in allowed list, reset to first allowed or 'discover' + if home_page and home_page not in allowed_pages: + home_page = allowed_pages[0] if allowed_pages else None + + profile_id = database.create_profile( + name, avatar_color, pin_hash, is_admin=False, avatar_url=avatar_url, + home_page=home_page, allowed_pages=allowed_pages, can_download=bool(can_download) + ) if profile_id is None: return jsonify({'success': False, 'error': 'Profile name already exists'}), 409 @@ -25598,6 +25661,37 @@ def update_profile(profile_id): return jsonify({'success': False, 'error': 'Cannot remove the last admin'}), 400 kwargs['is_admin'] = int(data['is_admin']) + # Home page — any user can change their own, admin can change anyone's + if 'home_page' in data: + hp = data['home_page'] or None + if hp and hp not in VALID_PAGE_IDS: + hp = None + # Non-admin self-edit: validate home_page is in their allowed pages + if not current['is_admin'] and current_pid == profile_id: + target = database.get_profile(profile_id) + ap = target.get('allowed_pages') if target else None + if ap is not None and hp and hp not in ap: + return jsonify({'success': False, 'error': 'Page not permitted'}), 400 + kwargs['home_page'] = hp + + # Allowed pages & can_download — admin only + if current['is_admin']: + if 'allowed_pages' in data: + ap = data['allowed_pages'] + if ap is not None: + ap = [p for p in ap if p in VALID_PAGE_IDS] + # Non-admin target should never have 'settings' + target = database.get_profile(profile_id) + if target and not target.get('is_admin'): + ap = [p for p in ap if p != 'settings'] + # If current home_page not in new allowed list, reset it + current_hp = kwargs.get('home_page') or (target.get('home_page') if target else None) + if current_hp and current_hp not in ap: + kwargs['home_page'] = ap[0] if ap else None + kwargs['allowed_pages'] = ap + if 'can_download' in data: + kwargs['can_download'] = int(bool(data['can_download'])) + success = database.update_profile(profile_id, **kwargs) return jsonify({'success': success}) except Exception as e: diff --git a/webui/index.html b/webui/index.html index 05b51b57..01166a17 100644 --- a/webui/index.html +++ b/webui/index.html @@ -56,6 +56,35 @@ +
+ + + +
+ + + + + + + + + +
+ +