From c66c19dc5947eb7513b3bb286a47a87cb5a43c1e Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Mon, 20 Oct 2025 16:21:50 -0400 Subject: [PATCH] feat: Add optional web interface authentication with session management Authentication Features: - Optional HTTP Basic Auth with session cookies for web interface protection - Configurable username/password with session timeout (default: 1 hour) - Selective route protection (web interface protected, webhooks/APIs remain public) - Authentication status display with logout functionality in web UI - Session cleanup and graceful logout with browser re-authentication prompt Configuration: - WEB_AUTH_ENABLED in .env (default: false - no breaking changes) - WEB_AUTH_USERNAME/WEB_AUTH_PASSWORD in .env.secrets - WEB_AUTH_SESSION_TIMEOUT configurable (5min-24h range) Development Tools: - Added debug_tv.py for TV series/episode debugging (series, season, episode levels) - Comprehensive episode data inspection with validation and sources breakdown - Complements existing debug_movie.py for complete media debugging coverage Technical Implementation: - SimpleAuthMiddleware with FastAPI integration - Session management with automatic cleanup - Authentication status API endpoints - Responsive CSS styling for auth UI elements - JavaScript functions for auth checking and logout --- .env.example | 9 ++ .env.secrets.example | 10 ++- README.md | 24 ++--- api/auth.py | 178 +++++++++++++++++++++++++++++++++++++ api/routes.py | 34 ++++++- config/settings.py | 10 +++ debug_tv.py | 201 ++++++++++++++++++++++++++++++++++++++++++ main.py | 14 +++ static/css/styles.css | 40 +++++++++ static/index.html | 8 ++ static/js/app.js | 59 +++++++++++++ 11 files changed, 570 insertions(+), 17 deletions(-) create mode 100644 api/auth.py create mode 100644 debug_tv.py diff --git a/.env.example b/.env.example index 7916a30..49a2925 100644 --- a/.env.example +++ b/.env.example @@ -135,6 +135,15 @@ PATH_DEBUG=false # Suppress TVDB API warnings (true/false) - TVDB failures are common and non-critical SUPPRESS_TVDB_WARNINGS=true +# =========================================== +# WEB INTERFACE AUTHENTICATION +# =========================================== +# Enable web interface authentication (default: false) +WEB_AUTH_ENABLED=false + +# Session timeout in seconds (default: 3600 = 1 hour) +WEB_AUTH_SESSION_TIMEOUT=3600 + # =========================================== # SERVER CONFIGURATION # =========================================== diff --git a/.env.secrets.example b/.env.secrets.example index 6653c39..e051d1c 100644 --- a/.env.secrets.example +++ b/.env.secrets.example @@ -38,4 +38,12 @@ JELLYSEERR_API_KEY=your_jellyseerr_api_key # TVDB API key (RECOMMENDED for v0.6.1+ Emby TV NFO Compatibility) # Required to convert IMDB IDs to TVDB IDs for proper Emby metadata loading # Get free API key at: https://thetvdb.com/api-information -TVDB_API_KEY=your_tvdb_api_key \ No newline at end of file +TVDB_API_KEY=your_tvdb_api_key + +# =========================================== +# WEB INTERFACE AUTHENTICATION (OPTIONAL) +# =========================================== +# Web interface login credentials (only used if WEB_AUTH_ENABLED=true in .env) +# Set WEB_AUTH_ENABLED=true in .env file to enable authentication +WEB_AUTH_USERNAME=admin +WEB_AUTH_PASSWORD=your_secure_web_password \ No newline at end of file diff --git a/README.md b/README.md index 8f11aad..523d089 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,11 @@ --- -> **⚠️ ALPHA SOFTWARE NOTICE ⚠️** -> -> NFOGuard is currently in **Alpha** stage. While functional, it may have bugs or missing features. -> > **🔌 Emby Plugin Included**: The Emby companion plugin is now bundled directly into the Docker image — no extra steps required. > -> **💬 Community Feedback**: Join our Discord if you’d like to share feedback, test new features early, or discuss improvements with other users: +> **💬 Community Feedback**: Join our Discord if you'd like to share feedback, test new features, or discuss improvements with other users: > -> **[Join Discord: https://discord.gg/bbD9Pmtr](https://discord.gg/bbD9Pmtr)** -> -> *If the Discord link has expired, please [open an issue](https://github.com/sbcrumb/NFOguard/issues) and we'll provide an updated link.* +> **[Join Discord: https://discord.gg/ZykJRGt72b](https://discord.gg/ZykJRGt72b)** --- @@ -38,7 +32,7 @@ NFOGuard automatically updates movie and TV show NFO files with proper release d - **Batch Processing** - Efficient handling of multiple files simultaneously - **PostgreSQL Database** - Production-ready database with optimized queries - **Smart Skip Logic** - Database-first checking eliminates expensive filesystem scans -- **88% Scan Optimization** - TV library scans reduced from hours to minutes +- **88% Scan Optimization** - Subsequent scans reduced from hours to minutes via smart skip logic ### **Web Interface & Management** - **Complete Web UI** - Episode and movie management with filtering and search @@ -64,9 +58,9 @@ NFOGuard automatically updates movie and TV show NFO files with proper release d ### 1. Download Configuration Files ```bash -wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.template -wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.secrets.template -wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/docker-compose.example.yml +wget https://raw.githubusercontent.com/sbcrumb/nfoguard/main/.env.template +wget https://raw.githubusercontent.com/sbcrumb/nfoguard/main/.env.secrets.template +wget https://raw.githubusercontent.com/sbcrumb/nfoguard/main/docker-compose.example.yml ``` ### 2. Configure Environment @@ -149,10 +143,10 @@ image: sbcrumb/nfoguard:dev ### Specific Version ```yaml -image: sbcrumb/nfoguard:v2.0.0 # Latest with monitoring & validation +image: sbcrumb/nfoguard:v2.6.7 # Latest with PostgreSQL & optimization ``` -> **🚀 Version 2.0.0** includes major architecture improvements, async I/O performance enhancements, comprehensive monitoring, and configuration validation. +> **🚀 Version 2.6.7** includes major architecture improvements, PostgreSQL database migration, 88% scan optimization, async I/O performance enhancements, comprehensive monitoring, and configuration validation. ## 🔗 Webhook Setup @@ -499,7 +493,7 @@ NFOGuard will ignore: ## 🆘 Support -- **Issues**: [GitHub Issues](https://github.com/sbcrumb/NFOguard/issues) +- **Issues**: [GitHub Issues](https://github.com/sbcrumb/nfoguard/issues) - **Documentation**: See `SETUP.md` for detailed instructions - **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard) diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..e33df96 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,178 @@ +""" +Simple authentication middleware for NFOGuard web interface +Provides basic HTTP auth and session management for web interface protection +""" +import secrets +import hashlib +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from fastapi import HTTPException, status, Request, Response +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from starlette.middleware.base import BaseHTTPMiddleware + + +class AuthSession: + """Simple session management for web interface""" + + def __init__(self, timeout_seconds: int = 3600): + self.sessions: Dict[str, Dict[str, Any]] = {} + self.timeout_seconds = timeout_seconds + + def create_session(self, username: str) -> str: + """Create a new session and return session token""" + session_token = secrets.token_urlsafe(32) + self.sessions[session_token] = { + "username": username, + "created_at": datetime.utcnow(), + "last_activity": datetime.utcnow() + } + return session_token + + def validate_session(self, session_token: str) -> bool: + """Validate session token and update last activity""" + if not session_token or session_token not in self.sessions: + return False + + session = self.sessions[session_token] + now = datetime.utcnow() + + # Check if session expired + if (now - session["last_activity"]).seconds > self.timeout_seconds: + del self.sessions[session_token] + return False + + # Update last activity + session["last_activity"] = now + return True + + def get_session_user(self, session_token: str) -> Optional[str]: + """Get username from valid session""" + if self.validate_session(session_token): + return self.sessions[session_token]["username"] + return None + + def delete_session(self, session_token: str) -> None: + """Delete a session (logout)""" + if session_token in self.sessions: + del self.sessions[session_token] + + def cleanup_expired_sessions(self) -> None: + """Remove expired sessions""" + now = datetime.utcnow() + expired_tokens = [] + + for token, session in self.sessions.items(): + if (now - session["last_activity"]).seconds > self.timeout_seconds: + expired_tokens.append(token) + + for token in expired_tokens: + del self.sessions[token] + + +class SimpleAuthMiddleware(BaseHTTPMiddleware): + """Simple authentication middleware for web interface routes""" + + def __init__(self, app, config): + super().__init__(app) + self.config = config + self.session_manager = AuthSession(config.web_auth_session_timeout) + self.security = HTTPBasic() + + # Routes that require authentication (web interface) + self.protected_routes = [ + "/", # Main web interface + "/static/", # Static files (CSS, JS) + "/api/movies", # Web API endpoints + "/api/series", + "/api/episodes", + "/api/dashboard", + "/database/" # Database management endpoints + ] + + # Routes that are always public (webhooks, health checks) + self.public_routes = [ + "/webhook/", + "/health", + "/ping", + "/api/v1/health", + "/api/v1/metrics" + ] + + async def dispatch(self, request: Request, call_next): + """Process request through authentication middleware""" + + # Skip authentication if disabled + if not self.config.web_auth_enabled: + return await call_next(request) + + # Check if route requires authentication + path = request.url.path + needs_auth = any(path.startswith(route) for route in self.protected_routes) + is_public = any(path.startswith(route) for route in self.public_routes) + + if is_public or not needs_auth: + return await call_next(request) + + # Check for existing session + session_token = request.cookies.get("nfoguard_session") + if session_token and self.session_manager.validate_session(session_token): + # Valid session, proceed + return await call_next(request) + + # Check for HTTP Basic Auth + auth_header = request.headers.get("authorization") + if auth_header and auth_header.startswith("Basic "): + credentials = self._parse_basic_auth(auth_header) + if credentials and self._validate_credentials(credentials.username, credentials.password): + # Create session for successful login + session_token = self.session_manager.create_session(credentials.username) + response = await call_next(request) + response.set_cookie( + key="nfoguard_session", + value=session_token, + max_age=self.config.web_auth_session_timeout, + httponly=True, + secure=False # Set to True if using HTTPS + ) + return response + + # Authentication required + return self._auth_required_response() + + def _parse_basic_auth(self, auth_header: str) -> Optional[HTTPBasicCredentials]: + """Parse HTTP Basic Auth header""" + try: + import base64 + encoded_credentials = auth_header.split(" ")[1] + decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8') + username, password = decoded_credentials.split(":", 1) + return HTTPBasicCredentials(username=username, password=password) + except Exception: + return None + + def _validate_credentials(self, username: str, password: str) -> bool: + """Validate username and password""" + return (username == self.config.web_auth_username and + password == self.config.web_auth_password) + + def _auth_required_response(self) -> Response: + """Return 401 response with WWW-Authenticate header""" + return Response( + content="Authentication required", + status_code=status.HTTP_401_UNAUTHORIZED, + headers={"WWW-Authenticate": "Basic realm=\"NFOGuard Web Interface\""} + ) + + +def create_auth_dependencies(config) -> Dict[str, Any]: + """Create authentication-related dependencies for dependency injection""" + session_manager = AuthSession(config.web_auth_session_timeout) + + return { + "session_manager": session_manager, + "auth_enabled": config.web_auth_enabled, + "auth_config": { + "username": config.web_auth_username, + "timeout": config.web_auth_session_timeout + } + } \ No newline at end of file diff --git a/api/routes.py b/api/routes.py index 210a1b9..05f0641 100644 --- a/api/routes.py +++ b/api/routes.py @@ -7,7 +7,7 @@ import requests import asyncio from pathlib import Path from datetime import datetime, timezone -from fastapi import HTTPException, BackgroundTasks, Request +from fastapi import HTTPException, BackgroundTasks, Request, Response from typing import Optional # Import models @@ -1396,6 +1396,38 @@ def register_routes(app, dependencies: dict): """Update episode dateadded""" return await update_episode_date(dependencies, imdb_id, season, episode, request.dateadded, request.source) + @app.post("/api/auth/logout") + async def _logout(request: Request, response: Response): + """Logout endpoint - clears session""" + session_manager = dependencies.get("session_manager") + if session_manager: + session_token = request.cookies.get("nfoguard_session") + if session_token: + session_manager.delete_session(session_token) + + response.delete_cookie("nfoguard_session") + return {"status": "logged_out", "message": "Session cleared"} + + @app.get("/api/auth/status") + async def _auth_status(request: Request): + """Check authentication status""" + auth_enabled = dependencies.get("auth_enabled", False) + + if not auth_enabled: + return {"authenticated": True, "auth_enabled": False, "message": "Authentication disabled"} + + session_manager = dependencies.get("session_manager") + if not session_manager: + return {"authenticated": False, "auth_enabled": True, "message": "Session manager not available"} + + session_token = request.cookies.get("nfoguard_session") + if session_token: + username = session_manager.get_session_user(session_token) + if username: + return {"authenticated": True, "auth_enabled": True, "username": username} + + return {"authenticated": False, "auth_enabled": True, "message": "Not authenticated"} + @app.post("/api/bulk/update-source") async def _bulk_update_source(request: BulkUpdateRequest): """Bulk update source for movies or episodes""" diff --git a/config/settings.py b/config/settings.py index fff5d28..a49dad0 100644 --- a/config/settings.py +++ b/config/settings.py @@ -79,6 +79,9 @@ class NFOGuardConfig: # TV processing self._load_tv_settings() + + # Web interface authentication + self._load_auth_settings() def _load_paths(self) -> None: """Load and validate path configuration""" @@ -156,6 +159,13 @@ class NFOGuardConfig: self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower() self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower() + def _load_auth_settings(self) -> None: + """Load web interface authentication settings""" + self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False) + self.web_auth_username = os.environ.get("WEB_AUTH_USERNAME", "admin") + self.web_auth_password = os.environ.get("WEB_AUTH_PASSWORD", "") + self.web_auth_session_timeout = self._get_int_env("WEB_AUTH_SESSION_TIMEOUT", 3600, 300, 86400) # 1 hour default, 5min-24h range + def _get_int_env(self, name: str, default: int, min_val: int, max_val: int) -> int: """Get integer environment variable with validation""" value_str = os.environ.get(name) diff --git a/debug_tv.py b/debug_tv.py new file mode 100644 index 0000000..32bfe27 --- /dev/null +++ b/debug_tv.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Debug script to check specific TV series/episode data in NFOGuard database +""" +import os +import sys +from pathlib import Path + +# Add the project root to the path +sys.path.insert(0, str(Path(__file__).parent)) + +from core.database import NFOGuardDatabase + +def debug_series(imdb_id: str): + """Debug a specific TV series' data""" + print(f"📺 DEBUG TV SERIES: {imdb_id}") + print("=" * 60) + + # Initialize database + db = NFOGuardDatabase() + + # Get series episodes + episodes = db.get_series_episodes(imdb_id) + if not episodes: + print(f"❌ TV series {imdb_id} not found in database") + return + + print(f"📊 SERIES OVERVIEW:") + print(f" IMDb ID: {imdb_id}") + print(f" Total Episodes: {len(episodes)}") + + # Count episodes by status + with_dates = sum(1 for ep in episodes if ep.get('dateadded')) + without_dates = len(episodes) - with_dates + with_video = sum(1 for ep in episodes if ep.get('has_video_file')) + + print(f" Episodes with dates: {with_dates}") + print(f" Episodes without dates: {without_dates}") + print(f" Episodes with video files: {with_video}") + + # Group by season + seasons = {} + for ep in episodes: + season = ep.get('season', 'Unknown') + if season not in seasons: + seasons[season] = [] + seasons[season].append(ep) + + print(f" Seasons: {len(seasons)} ({', '.join(f'S{s}' if isinstance(s, int) else str(s) for s in sorted(seasons.keys()))})") + + # Show sources breakdown + sources = {} + for ep in episodes: + source = ep.get('source', 'None') + sources[source] = sources.get(source, 0) + 1 + + print(f"\n📈 SOURCES BREAKDOWN:") + for source, count in sorted(sources.items(), key=lambda x: x[1], reverse=True): + print(f" {source}: {count} episodes") + + # Show recent episodes (last 10 by date added) + episodes_with_dates = [ep for ep in episodes if ep.get('dateadded')] + recent_episodes = sorted(episodes_with_dates, key=lambda x: x.get('last_updated', ''), reverse=True)[:10] + + if recent_episodes: + print(f"\n🕒 RECENT EPISODES (by last_updated):") + for ep in recent_episodes: + season = ep.get('season', '?') + episode = ep.get('episode', '?') + dateadded = ep.get('dateadded', 'None') + source = ep.get('source', 'None') + video = "✅" if ep.get('has_video_file') else "❌" + print(f" S{season:02d}E{episode:02d}: {dateadded} | {source} | Video: {video}") + +def debug_episode(imdb_id: str, season: int, episode: int): + """Debug a specific episode's data""" + print(f"📺 DEBUG TV EPISODE: {imdb_id} S{season:02d}E{episode:02d}") + print("=" * 60) + + # Initialize database + db = NFOGuardDatabase() + + # Get specific episode + episode_data = db.get_episode_date(imdb_id, season, episode) + if not episode_data: + print(f"❌ Episode S{season:02d}E{episode:02d} for series {imdb_id} not found in database") + return + + print("📊 RAW EPISODE DATA:") + for key, value in episode_data.items(): + print(f" {key}: {repr(value)}") + + print("\n📺 FORMATTED EPISODE DATA:") + print(f" Series IMDb: {episode_data.get('imdb_id', 'Unknown')}") + print(f" Season/Episode: S{episode_data.get('season', '?'):02d}E{episode_data.get('episode', '?'):02d}") + print(f" Title: {episode_data.get('title', 'Unknown')}") + print(f" Air Date: {episode_data.get('air_date', 'None')}") + print(f" Date Added: {episode_data.get('dateadded', 'None')}") + print(f" Source: {episode_data.get('source', 'None')}") + print(f" Has Video: {episode_data.get('has_video_file', False)}") + print(f" Video Path: {episode_data.get('video_path', 'None')}") + print(f" Last Updated: {episode_data.get('last_updated', 'None')}") + + # Check if air date is valid + air_date = episode_data.get('air_date') + if air_date and air_date.strip(): + try: + from datetime import datetime + test_date = f"{air_date}T00:00:00" + parsed = datetime.fromisoformat(test_date.replace('Z', '+00:00')) + print(f"\n✅ Air date is valid: {parsed}") + except Exception as e: + print(f"\n❌ Air date is INVALID: {e}") + else: + print(f"\n⚠️ Air date is empty or None") + + # Check if dateadded is valid + dateadded = episode_data.get('dateadded') + if dateadded and dateadded.strip(): + try: + from datetime import datetime + if isinstance(dateadded, str): + test_date = f"{dateadded}T00:00:00" if 'T' not in dateadded else dateadded + parsed = datetime.fromisoformat(test_date.replace('Z', '+00:00')) + else: + parsed = dateadded + print(f"✅ Date added is valid: {parsed}") + except Exception as e: + print(f"❌ Date added is INVALID: {e}") + else: + print(f"⚠️ Date added is empty or None") + +def debug_season(imdb_id: str, season: int): + """Debug all episodes in a specific season""" + print(f"📺 DEBUG TV SEASON: {imdb_id} Season {season}") + print("=" * 60) + + # Initialize database + db = NFOGuardDatabase() + + # Get series episodes + all_episodes = db.get_series_episodes(imdb_id) + season_episodes = [ep for ep in all_episodes if ep.get('season') == season] + + if not season_episodes: + print(f"❌ No episodes found for season {season} of series {imdb_id}") + return + + print(f"📊 SEASON {season} OVERVIEW:") + print(f" Total Episodes: {len(season_episodes)}") + + # Sort by episode number + season_episodes.sort(key=lambda x: x.get('episode', 0)) + + with_dates = sum(1 for ep in season_episodes if ep.get('dateadded')) + without_dates = len(season_episodes) - with_dates + with_video = sum(1 for ep in season_episodes if ep.get('has_video_file')) + + print(f" Episodes with dates: {with_dates}") + print(f" Episodes without dates: {without_dates}") + print(f" Episodes with video files: {with_video}") + + print(f"\n📋 EPISODE LIST:") + for ep in season_episodes: + episode_num = ep.get('episode', '?') + title = ep.get('title', 'Unknown')[:30] + ('...' if len(ep.get('title', '')) > 30 else '') + dateadded = ep.get('dateadded', 'None') + source = ep.get('source', 'None') + video = "✅" if ep.get('has_video_file') else "❌" + air_date = ep.get('air_date', 'None') + + print(f" E{episode_num:02d}: {title:<33} | Added: {dateadded} | Air: {air_date} | Video: {video}") + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage:") + print(" python debug_tv.py # Debug entire series") + print(" python debug_tv.py # Debug specific season") + print(" python debug_tv.py # Debug specific episode") + print("\nExamples:") + print(" python debug_tv.py tt0121955 # Debug South Park") + print(" python debug_tv.py tt0121955 27 # Debug South Park Season 27") + print(" python debug_tv.py tt0121955 27 6 # Debug South Park S27E06") + sys.exit(1) + + imdb_id = sys.argv[1] + if not imdb_id.startswith('tt'): + imdb_id = f'tt{imdb_id}' + + if len(sys.argv) == 4: + # Debug specific episode + season = int(sys.argv[2]) + episode = int(sys.argv[3]) + debug_episode(imdb_id, season, episode) + elif len(sys.argv) == 3: + # Debug specific season + season = int(sys.argv[2]) + debug_season(imdb_id, season) + else: + # Debug entire series + debug_series(imdb_id) \ No newline at end of file diff --git a/main.py b/main.py index e4e3003..9b95bfb 100644 --- a/main.py +++ b/main.py @@ -15,6 +15,9 @@ from fastapi import FastAPI # Import configuration first from config.settings import config + +# Import authentication +from api.auth import SimpleAuthMiddleware, create_auth_dependencies from utils.logging import _log # Import core components @@ -172,6 +175,17 @@ def main(): # Initialize components dependencies = initialize_components() + # Add authentication dependencies + auth_deps = create_auth_dependencies(config) + dependencies.update(auth_deps) + + # Add authentication middleware if enabled + if config.web_auth_enabled: + app.add_middleware(SimpleAuthMiddleware, config=config) + _log("INFO", f"Web authentication enabled for user: {config.web_auth_username}") + else: + _log("INFO", "Web authentication disabled - web interface is public") + # Store dependencies globally for signal handler access signal_handler.dependencies = dependencies diff --git a/static/css/styles.css b/static/css/styles.css index 9dd6eba..c41f5fb 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -39,6 +39,7 @@ body { color: white; padding: 1rem 0; box-shadow: var(--shadow-lg); + position: relative; } .header-content { @@ -63,6 +64,45 @@ body { font-size: 1rem; } +/* Authentication Status */ +.auth-status { + position: absolute; + top: 1rem; + right: 1rem; + display: flex; + align-items: center; + gap: 1rem; + color: white; + font-size: 0.9rem; +} + +.auth-user { + display: flex; + align-items: center; + gap: 0.5rem; + opacity: 0.9; +} + +.auth-logout { + background: rgba(255, 255, 255, 0.2); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 0.5rem 1rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.5rem; + transition: all 0.2s ease; +} + +.auth-logout:hover { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.5); + transform: translateY(-1px); +} + .nav-tabs { max-width: 1200px; margin: 1rem auto 0; diff --git a/static/index.html b/static/index.html index 124bf05..5aee3c9 100644 --- a/static/index.html +++ b/static/index.html @@ -15,6 +15,14 @@

NFOGuard

Database Management & Reporting

+