working now
This commit is contained in:
@@ -34,8 +34,8 @@ RUN addgroup --system --gid 1001 delphi \
|
|||||||
COPY --chown=delphi:delphi . .
|
COPY --chown=delphi:delphi . .
|
||||||
|
|
||||||
# Create necessary directories with proper permissions
|
# Create necessary directories with proper permissions
|
||||||
RUN mkdir -p /app/data /app/uploads /app/backups /app/exports \
|
RUN mkdir -p /app/data /app/uploads /app/backups /app/exports /app/logs \
|
||||||
&& chown -R delphi:delphi /app/data /app/uploads /app/backups /app/exports
|
&& chown -R delphi:delphi /app/data /app/uploads /app/backups /app/exports /app/logs
|
||||||
|
|
||||||
# Create volume mount points
|
# Create volume mount points
|
||||||
VOLUME ["/app/data", "/app/uploads", "/app/backups"]
|
VOLUME ["/app/data", "/app/uploads", "/app/backups"]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Authentication API endpoints
|
|||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import List
|
from typing import List
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -24,15 +24,39 @@ from app.auth.schemas import (
|
|||||||
ThemePreferenceUpdate
|
ThemePreferenceUpdate
|
||||||
)
|
)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.core.logging import get_logger, log_auth_attempt
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = get_logger("auth")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
async def login(login_data: LoginRequest, db: Session = Depends(get_db)):
|
async def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_db)):
|
||||||
"""Login endpoint"""
|
"""Login endpoint"""
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
user_agent = request.headers.get("user-agent", "")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Login attempt started",
|
||||||
|
username=login_data.username,
|
||||||
|
client_ip=client_ip,
|
||||||
|
user_agent=user_agent
|
||||||
|
)
|
||||||
|
|
||||||
user = authenticate_user(db, login_data.username, login_data.password)
|
user = authenticate_user(db, login_data.username, login_data.password)
|
||||||
if not user:
|
if not user:
|
||||||
|
log_auth_attempt(
|
||||||
|
username=login_data.username,
|
||||||
|
success=False,
|
||||||
|
ip_address=client_ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
error="Invalid credentials"
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"Login failed - invalid credentials",
|
||||||
|
username=login_data.username,
|
||||||
|
client_ip=client_ip
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Incorrect username or password",
|
detail="Incorrect username or password",
|
||||||
@@ -47,6 +71,20 @@ async def login(login_data: LoginRequest, db: Session = Depends(get_db)):
|
|||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
data={"sub": user.username}, expires_delta=access_token_expires
|
data={"sub": user.username}, expires_delta=access_token_expires
|
||||||
)
|
)
|
||||||
|
|
||||||
|
log_auth_attempt(
|
||||||
|
username=login_data.username,
|
||||||
|
success=True,
|
||||||
|
ip_address=client_ip,
|
||||||
|
user_agent=user_agent
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Login successful",
|
||||||
|
username=login_data.username,
|
||||||
|
user_id=user.id,
|
||||||
|
client_ip=client_ip
|
||||||
|
)
|
||||||
|
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
@@ -87,6 +125,7 @@ async def register(
|
|||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def read_users_me(current_user: User = Depends(get_current_user)):
|
async def read_users_me(current_user: User = Depends(get_current_user)):
|
||||||
"""Get current user info"""
|
"""Get current user info"""
|
||||||
|
logger.debug("User info requested", username=current_user.username, user_id=current_user.id)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ class Settings(BaseSettings):
|
|||||||
secure_cookies: bool = False
|
secure_cookies: bool = False
|
||||||
compose_project_name: Optional[str] = None
|
compose_project_name: Optional[str] = None
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_level: str = "INFO"
|
||||||
|
log_to_file: bool = True
|
||||||
|
log_rotation: str = "10 MB"
|
||||||
|
log_retention: str = "30 days"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|
||||||
|
|||||||
16
app/main.py
16
app/main.py
@@ -12,18 +12,31 @@ from app.database.base import engine
|
|||||||
from app.models import BaseModel
|
from app.models import BaseModel
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.auth.security import get_admin_user
|
from app.auth.security import get_admin_user
|
||||||
|
from app.core.logging import setup_logging, get_logger
|
||||||
|
from app.middleware.logging import LoggingMiddleware
|
||||||
|
|
||||||
|
# Initialize logging
|
||||||
|
setup_logging()
|
||||||
|
logger = get_logger("main")
|
||||||
|
|
||||||
# Create database tables
|
# Create database tables
|
||||||
|
logger.info("Creating database tables")
|
||||||
BaseModel.metadata.create_all(bind=engine)
|
BaseModel.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
|
logger.info("Initializing FastAPI application", version=settings.app_version, debug=settings.debug)
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.app_name,
|
title=settings.app_name,
|
||||||
version=settings.app_version,
|
version=settings.app_version,
|
||||||
description="Modern Python web application for Delphi Consulting Group",
|
description="Modern Python web application for Delphi Consulting Group",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add logging middleware
|
||||||
|
logger.info("Adding request logging middleware")
|
||||||
|
app.add_middleware(LoggingMiddleware, log_requests=True, log_responses=settings.debug)
|
||||||
|
|
||||||
# Configure CORS
|
# Configure CORS
|
||||||
|
logger.info("Configuring CORS middleware")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"], # Configure appropriately for production
|
allow_origins=["*"], # Configure appropriately for production
|
||||||
@@ -33,10 +46,12 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Mount static files
|
# Mount static files
|
||||||
|
logger.info("Mounting static file directories")
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|
||||||
# Templates
|
# Templates
|
||||||
|
logger.info("Initializing Jinja2 templates")
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
@@ -51,6 +66,7 @@ from app.api.import_data import router as import_router
|
|||||||
from app.api.support import router as support_router
|
from app.api.support import router as support_router
|
||||||
from app.api.settings import router as settings_router
|
from app.api.settings import router as settings_router
|
||||||
|
|
||||||
|
logger.info("Including API routers")
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["authentication"])
|
app.include_router(auth_router, prefix="/api/auth", tags=["authentication"])
|
||||||
app.include_router(customers_router, prefix="/api/customers", tags=["customers"])
|
app.include_router(customers_router, prefix="/api/customers", tags=["customers"])
|
||||||
app.include_router(files_router, prefix="/api/files", tags=["files"])
|
app.include_router(files_router, prefix="/api/files", tags=["files"])
|
||||||
|
|||||||
0
app/middleware/__init__.py
Normal file
0
app/middleware/__init__.py
Normal file
130
app/middleware/logging.py
Normal file
130
app/middleware/logging.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
Request/Response Logging Middleware
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
from typing import Callable
|
||||||
|
from fastapi import Request, Response
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import StreamingResponse
|
||||||
|
from app.core.logging import get_logger, log_request
|
||||||
|
|
||||||
|
logger = get_logger("middleware.logging")
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Middleware to log HTTP requests and responses"""
|
||||||
|
|
||||||
|
def __init__(self, app, log_requests: bool = True, log_responses: bool = False):
|
||||||
|
super().__init__(app)
|
||||||
|
self.log_requests = log_requests
|
||||||
|
self.log_responses = log_responses
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
|
# Skip logging for static files and health checks
|
||||||
|
skip_paths = ["/static/", "/uploads/", "/health", "/favicon.ico"]
|
||||||
|
if any(request.url.path.startswith(path) for path in skip_paths):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Record start time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Extract request details
|
||||||
|
client_ip = self.get_client_ip(request)
|
||||||
|
user_agent = request.headers.get("user-agent", "")
|
||||||
|
|
||||||
|
# Log request
|
||||||
|
if self.log_requests:
|
||||||
|
logger.info(
|
||||||
|
"Request started",
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
query_params=str(request.query_params) if request.query_params else None,
|
||||||
|
client_ip=client_ip,
|
||||||
|
user_agent=user_agent
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process request
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
except Exception as e:
|
||||||
|
# Log exceptions
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
logger.error(
|
||||||
|
"Request failed with exception",
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
error=str(e),
|
||||||
|
client_ip=client_ip
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Calculate duration
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
# Extract user ID from request if available (for authenticated requests)
|
||||||
|
user_id = None
|
||||||
|
if hasattr(request.state, "user") and request.state.user:
|
||||||
|
user_id = getattr(request.state.user, "id", None) or getattr(request.state.user, "username", None)
|
||||||
|
|
||||||
|
# Log response
|
||||||
|
log_request(
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
status_code=response.status_code,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log response details if enabled
|
||||||
|
if self.log_responses:
|
||||||
|
logger.debug(
|
||||||
|
"Response details",
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=dict(response.headers),
|
||||||
|
size_bytes=response.headers.get("content-length"),
|
||||||
|
content_type=response.headers.get("content-type")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log slow requests as warnings
|
||||||
|
if duration_ms > 1000: # More than 1 second
|
||||||
|
logger.warning(
|
||||||
|
"Slow request detected",
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
status_code=response.status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log authentication-related requests to auth log
|
||||||
|
if any(path in request.url.path for path in ["/api/auth/", "/login", "/logout"]):
|
||||||
|
logger.bind(name="auth").info(
|
||||||
|
"Auth endpoint accessed",
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
status_code=response.status_code,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
client_ip=client_ip,
|
||||||
|
user_agent=user_agent
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_client_ip(self, request: Request) -> str:
|
||||||
|
"""Extract client IP address from request headers"""
|
||||||
|
# Check for IP in common proxy headers
|
||||||
|
forwarded_for = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded_for:
|
||||||
|
# Take the first IP in the chain
|
||||||
|
return forwarded_for.split(",")[0].strip()
|
||||||
|
|
||||||
|
real_ip = request.headers.get("x-real-ip")
|
||||||
|
if real_ip:
|
||||||
|
return real_ip
|
||||||
|
|
||||||
|
# Fallback to direct client IP
|
||||||
|
if request.client:
|
||||||
|
return request.client.host
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
@@ -9,6 +9,9 @@ from fastapi import Request
|
|||||||
|
|
||||||
from app.models.audit import AuditLog, LoginAttempt
|
from app.models.audit import AuditLog, LoginAttempt
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("audit")
|
||||||
|
|
||||||
|
|
||||||
class AuditService:
|
class AuditService:
|
||||||
@@ -73,7 +76,7 @@ class AuditService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
# Log the error but don't fail the main operation
|
# Log the error but don't fail the main operation
|
||||||
print(f"Failed to log audit entry: {e}")
|
logger.error("Failed to log audit entry", error=str(e), action=action, user_id=user_id)
|
||||||
return audit_log
|
return audit_log
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -128,7 +131,7 @@ class AuditService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
# Log the error but don't fail the main operation
|
# Log the error but don't fail the main operation
|
||||||
print(f"Failed to log login attempt: {e}")
|
logger.error("Failed to log login attempt", error=str(e), username=username, success=success)
|
||||||
return login_attempt
|
return login_attempt
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -31,4 +31,7 @@ pytest-asyncio==0.24.0
|
|||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
loguru==0.7.2
|
||||||
@@ -1643,8 +1643,48 @@ async function createBackup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadBackup(filename) {
|
async function downloadBackup(filename) {
|
||||||
window.open('/api/admin/backup/download', '_blank');
|
try {
|
||||||
|
const response = await fetch('/api/admin/backup/download', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMsg = 'Failed to download backup';
|
||||||
|
try {
|
||||||
|
const err = await response.json();
|
||||||
|
errorMsg = err.detail || errorMsg;
|
||||||
|
} catch (_) {}
|
||||||
|
showAlert(errorMsg, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Try to extract filename from headers if provided
|
||||||
|
let suggestedName = filename || 'database_backup.db';
|
||||||
|
const disp = response.headers.get('Content-Disposition') || response.headers.get('content-disposition');
|
||||||
|
if (disp) {
|
||||||
|
const match = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(disp);
|
||||||
|
const extracted = match && (match[1] || match[2]);
|
||||||
|
if (extracted) {
|
||||||
|
try { suggestedName = decodeURIComponent(extracted); } catch (_) { suggestedName = extracted; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = suggestedName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Backup download failed:', error);
|
||||||
|
showAlert('Failed to download backup', 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility Functions
|
// Utility Functions
|
||||||
|
|||||||
@@ -407,7 +407,7 @@
|
|||||||
// Advanced Search JavaScript
|
// Advanced Search JavaScript
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Initialize search components
|
// Initialize search components
|
||||||
initializeSearch();
|
initializeAdvancedSearch();
|
||||||
loadSearchFacets();
|
loadSearchFacets();
|
||||||
setupEventHandlers();
|
setupEventHandlers();
|
||||||
setupKeyboardShortcuts();
|
setupKeyboardShortcuts();
|
||||||
@@ -424,7 +424,7 @@ let currentSearchCriteria = {};
|
|||||||
let searchTimeout;
|
let searchTimeout;
|
||||||
let facetsData = {};
|
let facetsData = {};
|
||||||
|
|
||||||
function initializeSearch() {
|
function initializeAdvancedSearch() {
|
||||||
// Set default date for today
|
// Set default date for today
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user