refactorisation pour correction de sécurité
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled

This commit is contained in:
2026-03-03 08:29:52 -05:00
parent 21d99a0f48
commit 88742892d0
18 changed files with 3016 additions and 9085 deletions
-6585
View File
File diff suppressed because it is too large Load Diff
+109 -109
View File
@@ -13,12 +13,12 @@ const containersPage = {
inspectData: null,
_initialized: false,
_initPromise: null,
// View settings
viewMode: 'comfortable', // 'comfortable', 'compact', 'grouped'
currentPage: 1,
perPage: 50,
// Filter state
filters: {
search: '',
@@ -30,10 +30,10 @@ const containersPage = {
favoritesOnly: false,
// ========== INITIALIZATION ==========
async init() {
if (this._initPromise) return await this._initPromise;
this._initPromise = (async () => {
this.setupEventListeners();
this.setupKeyboardShortcuts();
@@ -43,7 +43,7 @@ const containersPage = {
await this.loadData();
this._initialized = true;
})();
return await this._initPromise;
},
@@ -150,7 +150,7 @@ const containersPage = {
document.addEventListener('keydown', (e) => {
// Only active when on containers page
if (currentPage !== 'docker-containers') return;
// Ignore if in input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') {
// Escape clears search
@@ -184,7 +184,7 @@ const containersPage = {
async loadData() {
this.showLoading();
try {
const [containersRes, hostsRes] = await Promise.all([
this.fetchAPI('/api/docker/containers'),
@@ -193,16 +193,16 @@ const containersPage = {
this.containers = containersRes.containers || [];
this.hosts = hostsRes.hosts || [];
// Update host filter dropdown
this.populateHostFilter();
// Update stats
this.updateStats(containersRes);
// Apply filters and render
this.applyFilters();
} catch (error) {
console.error('Error loading containers:', error);
this.showError(error.message);
@@ -212,9 +212,9 @@ const containersPage = {
async refresh() {
const icon = document.getElementById('containers-refresh-icon');
if (icon) icon.classList.add('fa-spin');
await this.loadData();
if (icon) icon.classList.remove('fa-spin');
this.showToast('Données actualisées', 'success');
},
@@ -225,16 +225,16 @@ const containersPage = {
'Content-Type': 'application/json',
...options.headers
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${window.location.origin}${endpoint}`, {
...options,
headers
});
if (!response.ok) {
if (response.status === 401) {
this.showToast('Session expirée', 'error');
@@ -243,7 +243,7 @@ const containersPage = {
}
throw new Error(`API Error: ${response.status}`);
}
return response.json();
},
@@ -251,22 +251,22 @@ const containersPage = {
applyFilters() {
let result = [...this.containers];
// Text search with smart tokens
if (this.filters.search) {
result = this.smartSearch(result, this.filters.search);
}
// Status filter
if (this.filters.status) {
result = result.filter(c => c.state === this.filters.status);
}
// Host filter
if (this.filters.host) {
result = result.filter(c => c.host_id === this.filters.host);
}
// Health filter
if (this.filters.health) {
if (this.filters.health === 'none') {
@@ -280,10 +280,10 @@ const containersPage = {
if (this.favoritesOnly && window.favoritesManager) {
result = result.filter(c => window.favoritesManager.isFavorite(c.host_id, c.container_id));
}
// Sort
result = this.sortContainers(result, this.sortBy);
this.filteredContainers = result;
this.currentPage = 1;
this.updateActiveFilters();
@@ -292,7 +292,7 @@ const containersPage = {
smartSearch(containers, query) {
const tokens = this.parseSearchTokens(query);
return containers.filter(c => {
// Check token filters
for (const token of tokens.filters) {
@@ -319,7 +319,7 @@ const containersPage = {
break;
}
}
// Free text search
if (tokens.freeText) {
const searchStr = tokens.freeText.toLowerCase();
@@ -330,10 +330,10 @@ const containersPage = {
c.compose_project,
c.container_id?.substring(0, 12)
].filter(Boolean).join(' ').toLowerCase();
if (!searchable.includes(searchStr)) return false;
}
return true;
});
},
@@ -341,16 +341,16 @@ const containersPage = {
parseSearchTokens(query) {
const filters = [];
let freeText = query;
// Match tokens like "host:value" or "status:running"
const tokenRegex = /(\w+):(\S+)/g;
let match;
while ((match = tokenRegex.exec(query)) !== null) {
filters.push({ key: match[1], value: match[2] });
freeText = freeText.replace(match[0], '').trim();
}
return { filters, freeText };
},
@@ -363,10 +363,10 @@ const containersPage = {
sortContainers(containers, sortBy) {
const [field, direction] = sortBy.split('-');
const mult = direction === 'desc' ? -1 : 1;
return containers.sort((a, b) => {
let valA, valB;
switch (field) {
case 'name':
valA = a.name.toLowerCase();
@@ -389,7 +389,7 @@ const containersPage = {
valA = a.name.toLowerCase();
valB = b.name.toLowerCase();
}
if (valA < valB) return -1 * mult;
if (valA > valB) return 1 * mult;
return 0;
@@ -403,10 +403,10 @@ const containersPage = {
const empty = document.getElementById('containers-empty');
const error = document.getElementById('containers-error');
const pagination = document.getElementById('containers-pagination');
// Hide error
error?.classList.add('hidden');
// Check empty
if (this.filteredContainers.length === 0) {
list.innerHTML = '';
@@ -414,24 +414,24 @@ const containersPage = {
pagination?.classList.add('hidden');
return;
}
empty?.classList.add('hidden');
// Paginate
const start = (this.currentPage - 1) * this.perPage;
const end = Math.min(start + this.perPage, this.filteredContainers.length);
const pageContainers = this.filteredContainers.slice(start, end);
// Render based on view mode
if (this.viewMode === 'grouped') {
list.innerHTML = this.renderGroupedView(pageContainers);
} else {
list.innerHTML = pageContainers.map(c => this.renderContainerRow(c)).join('');
}
// Update pagination
this.updatePagination(start, end);
// Update search clear button visibility
const clearBtn = document.getElementById('containers-search-clear');
if (clearBtn) {
@@ -462,19 +462,19 @@ const containersPage = {
const iconHtml = iconKey
? `<span class="w-5 h-5 rounded flex items-center justify-center border border-gray-700" style="${bgStyle}"><span class="iconify text-sm" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(iconColor)}"></span></span>`
: '';
const healthBadge = c.health && c.health !== 'none' ? `
<span class="px-2 py-0.5 rounded text-xs bg-${c.health === 'healthy' ? 'green' : 'red'}-500/20 text-${c.health === 'healthy' ? 'green' : 'red'}-400">
${c.health}
</span>
` : '';
const projectBadge = c.compose_project ? `
<span class="px-2 py-0.5 rounded text-xs bg-purple-500/20 text-purple-400">${this.escapeHtml(c.compose_project)}</span>
` : '';
const portLinks = this.renderPortLinks(c);
if (isCompact) {
return `
<div class="bg-gray-800/40 rounded-lg px-3 py-2 flex items-center gap-3 hover:bg-gray-800/60 transition-colors cursor-pointer"
@@ -499,7 +499,7 @@ const containersPage = {
</div>
`;
}
return `
<div class="bg-gray-800/40 rounded-lg p-4 hover:bg-gray-800/60 transition-colors cursor-pointer"
onclick="containersPage.openDrawer('${c.host_id}', '${c.container_id}')"
@@ -566,7 +566,7 @@ const containersPage = {
}
groups[c.host_id].containers.push(c);
});
return Object.entries(groups).map(([hostId, group]) => `
<div class="glass-card overflow-hidden mb-4">
<div class="bg-gray-800/60 px-4 py-3 flex items-center justify-between border-b border-gray-700">
@@ -586,7 +586,7 @@ const containersPage = {
renderQuickActions(c) {
const isRunning = c.state === 'running';
return `
${!isRunning ? `
<button onclick="event.stopPropagation(); containersPage.containerAction('${c.host_id}', '${c.container_id}', 'start')"
@@ -612,26 +612,26 @@ const containersPage = {
renderPortLinks(c) {
if (!c.ports) return '';
const portStr = c.ports.raw || (typeof c.ports === 'string' ? c.ports : '');
if (!portStr) return '';
const portRegex = /(?:([\d.]+):)?(\d+)->\d+\/tcp/g;
const links = [];
const seenPorts = new Set();
let match;
while ((match = portRegex.exec(portStr)) !== null) {
const bindIp = match[1] || '0.0.0.0';
const hostPort = match[2];
if (bindIp === '127.0.0.1' || bindIp === '::1') continue;
if (seenPorts.has(hostPort)) continue;
seenPorts.add(hostPort);
const protocol = ['443', '8443', '9443'].includes(hostPort) ? 'https' : 'http';
const url = `${protocol}://${c.host_ip}:${hostPort}`;
links.push(`
<a href="${url}" target="_blank" rel="noopener noreferrer"
onclick="event.stopPropagation()"
@@ -640,7 +640,7 @@ const containersPage = {
</a>
`);
}
return links.slice(0, 3).join(''); // Limit to 3 port links
},
@@ -675,17 +675,17 @@ const containersPage = {
const elem = document.getElementById(id);
if (elem) elem.textContent = val;
};
el('containers-total', data.total || 0);
el('containers-running', data.running || 0);
el('containers-stopped', data.stopped || 0);
el('containers-paused', data.paused || 0);
el('containers-hosts-count', data.hosts_count || 0);
// Update last update time
if (data.last_update) {
const date = new Date(data.last_update);
document.getElementById('containers-last-update').textContent =
document.getElementById('containers-last-update').textContent =
`Mis à jour ${this.formatRelativeTime(date)}`;
}
},
@@ -693,12 +693,12 @@ const containersPage = {
updatePagination(start, end) {
const pagination = document.getElementById('containers-pagination');
if (!pagination) return;
if (this.filteredContainers.length <= this.perPage) {
pagination.classList.add('hidden');
return;
}
pagination.classList.remove('hidden');
document.getElementById('containers-showing-start').textContent = start + 1;
document.getElementById('containers-showing-end').textContent = end;
@@ -708,9 +708,9 @@ const containersPage = {
updateActiveFilters() {
const container = document.getElementById('containers-active-filters');
if (!container) return;
const activeFilters = [];
if (this.filters.status) {
activeFilters.push({ key: 'status', value: this.filters.status, label: `Status: ${this.filters.status}` });
}
@@ -721,12 +721,12 @@ const containersPage = {
if (this.filters.health) {
activeFilters.push({ key: 'health', value: this.filters.health, label: `Health: ${this.filters.health}` });
}
if (activeFilters.length === 0) {
container.classList.add('hidden');
return;
}
container.classList.remove('hidden');
container.innerHTML = activeFilters.map(f => `
<span class="inline-flex items-center gap-1 px-2 py-1 bg-purple-500/20 text-purple-300 rounded text-sm">
@@ -745,10 +745,10 @@ const containersPage = {
populateHostFilter() {
const select = document.getElementById('containers-filter-host');
if (!select) return;
// Keep first option
select.innerHTML = '<option value="">Tous les hosts</option>';
this.hosts.forEach(host => {
const option = document.createElement('option');
option.value = host.host_id;
@@ -759,7 +759,7 @@ const containersPage = {
setViewMode(mode) {
this.viewMode = mode;
// Update button states
['comfortable', 'compact', 'grouped'].forEach(m => {
const btn = document.getElementById(`containers-view-${m}`);
@@ -769,7 +769,7 @@ const containersPage = {
btn.setAttribute('aria-pressed', m === mode ? 'true' : 'false');
}
});
this.render();
},
@@ -794,11 +794,11 @@ const containersPage = {
async containerAction(hostId, containerId, action) {
try {
this.showToast(`${action}...`, 'info');
const response = await this.fetchAPI(`/api/docker/containers/${hostId}/${containerId}/${action}`, {
method: 'POST'
});
if (response.success) {
this.showToast(`Container ${action} réussi`, 'success');
// Refresh after a short delay
@@ -816,12 +816,12 @@ const containersPage = {
this.showToast('Aucun container sélectionné', 'warning');
return;
}
const confirmMsg = `Êtes-vous sûr de vouloir ${action} ${this.selectedIds.size} container(s) ?`;
if (!confirm(confirmMsg)) return;
this.showToast(`${action} en cours...`, 'info');
const results = await Promise.allSettled(
Array.from(this.selectedIds).map(id => {
const [hostId, containerId] = id.split('::');
@@ -830,11 +830,11 @@ const containersPage = {
});
})
);
const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length;
this.showToast(`${successful}/${this.selectedIds.size} container(s) ${action}`,
successful === this.selectedIds.size ? 'success' : 'warning');
this.showToast(`${successful}/${this.selectedIds.size} container(s) ${action}`,
successful === this.selectedIds.size ? 'success' : 'warning');
this.selectedIds.clear();
this.updateBulkActionsBar();
setTimeout(() => this.refresh(), 1000);
@@ -865,7 +865,7 @@ const containersPage = {
const bar = document.getElementById('containers-bulk-actions');
const count = document.getElementById('containers-selected-count');
const selectAll = document.getElementById('containers-select-all');
if (bar) {
bar.classList.toggle('hidden', this.selectedIds.size === 0);
}
@@ -873,8 +873,8 @@ const containersPage = {
count.textContent = this.selectedIds.size;
}
if (selectAll) {
selectAll.checked = this.selectedIds.size > 0 &&
this.selectedIds.size === this.filteredContainers.length;
selectAll.checked = this.selectedIds.size > 0 &&
this.selectedIds.size === this.filteredContainers.length;
}
},
@@ -883,34 +883,34 @@ const containersPage = {
async openDrawer(hostId, containerId, tab = 'overview') {
const wantedHostId = String(hostId);
const wantedContainerId = String(containerId);
if (!this._initialized || !this.containers || this.containers.length === 0) {
await this.ensureInit();
}
this.currentContainer = this.containers.find(
c => String(c.host_id) === wantedHostId && String(c.container_id) === wantedContainerId
);
if (!this.currentContainer) {
this.showToast('Container non trouvé', 'error');
return;
}
const drawer = document.getElementById('container-drawer');
const backdrop = document.getElementById('container-drawer-backdrop');
// Populate drawer
this.populateDrawer();
// Show drawer
drawer.classList.remove('translate-x-full');
backdrop.classList.remove('hidden');
document.body.style.overflow = 'hidden';
// Switch to requested tab
this.switchDrawerTab(tab);
// Load tab-specific data
if (tab === 'logs') {
await this.loadLogs();
@@ -922,11 +922,11 @@ const containersPage = {
closeDrawer() {
const drawer = document.getElementById('container-drawer');
const backdrop = document.getElementById('container-drawer-backdrop');
drawer.classList.add('translate-x-full');
backdrop.classList.add('hidden');
document.body.style.overflow = '';
this.currentContainer = null;
this.inspectData = null;
},
@@ -939,7 +939,7 @@ const containersPage = {
populateDrawer() {
const c = this.currentContainer;
if (!c) return;
const stateColors = {
running: 'bg-green-500',
exited: 'bg-red-500',
@@ -948,10 +948,10 @@ const containersPage = {
restarting: 'bg-orange-500',
dead: 'bg-red-500'
};
// Header
document.getElementById('drawer-container-name').textContent = c.name;
document.getElementById('drawer-container-state').className =
document.getElementById('drawer-container-state').className =
`w-3 h-3 rounded-full ${stateColors[c.state] || 'bg-gray-500'}`;
const custom = c.customization || window.containerCustomizationsManager?.get(c.host_id, c.container_id);
@@ -965,7 +965,7 @@ const containersPage = {
? `<span class="w-6 h-6 rounded flex items-center justify-center border border-gray-700" style="${bgStyle}"><span class="iconify text-base" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(iconColor)}"></span></span>`
: '';
}
// Overview
document.getElementById('drawer-host-name').textContent = c.host_name;
document.getElementById('drawer-host-ip').textContent = c.host_ip;
@@ -973,7 +973,7 @@ const containersPage = {
document.getElementById('drawer-health').textContent = c.health || 'No healthcheck';
document.getElementById('drawer-image').textContent = c.image || '—';
document.getElementById('drawer-container-id').textContent = c.container_id;
// Ports
const portsEl = document.getElementById('drawer-ports');
if (c.ports) {
@@ -981,7 +981,7 @@ const containersPage = {
} else {
portsEl.innerHTML = '<span class="text-gray-500">Aucun port exposé</span>';
}
// Labels
const labelsEl = document.getElementById('drawer-labels');
if (c.labels && Object.keys(c.labels).length > 0) {
@@ -995,7 +995,7 @@ const containersPage = {
} else {
labelsEl.innerHTML = '<span class="text-gray-500">Aucun label</span>';
}
// Update action buttons based on state
const isRunning = c.state === 'running';
document.getElementById('drawer-btn-start')?.classList.toggle('hidden', isRunning);
@@ -1008,12 +1008,12 @@ const containersPage = {
tab.classList.toggle('active', tab.dataset.tab === tabName);
tab.classList.toggle('text-gray-400', tab.dataset.tab !== tabName);
});
// Update tab content
document.querySelectorAll('.drawer-tab-content').forEach(content => {
content.classList.toggle('hidden', !content.id.endsWith(tabName));
});
// Load content if needed
if (tabName === 'logs' && this.currentContainer) {
this.loadLogs();
@@ -1024,18 +1024,18 @@ const containersPage = {
async loadLogs() {
if (!this.currentContainer) return;
const logsEl = document.getElementById('drawer-logs-content');
logsEl.textContent = 'Chargement...';
try {
const tail = document.getElementById('drawer-logs-tail')?.value || 200;
const timestamps = document.getElementById('drawer-logs-timestamps')?.checked || false;
const response = await this.fetchAPI(
`/api/docker/containers/${this.currentContainer.host_id}/${this.currentContainer.container_id}/logs?tail=${tail}&timestamps=${timestamps}`
);
logsEl.textContent = response.logs || 'Aucun log disponible';
} catch (error) {
logsEl.textContent = `Erreur: ${error.message}`;
@@ -1044,15 +1044,15 @@ const containersPage = {
async loadInspect() {
if (!this.currentContainer) return;
const inspectEl = document.getElementById('drawer-inspect-content');
inspectEl.textContent = 'Chargement...';
try {
const response = await this.fetchAPI(
`/api/docker/containers/${this.currentContainer.host_id}/${this.currentContainer.container_id}/inspect`
);
this.inspectData = response.inspect_data || {};
inspectEl.textContent = JSON.stringify(this.inspectData, null, 2);
} catch (error) {
@@ -1065,7 +1065,7 @@ const containersPage = {
this.showToast('Aucune donnée à copier', 'warning');
return;
}
try {
await navigator.clipboard.writeText(JSON.stringify(this.inspectData, null, 2));
this.showToast('JSON copié !', 'success');
@@ -1105,8 +1105,8 @@ const containersPage = {
const now = new Date();
const d = new Date(date);
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return 'À l\'instant';
if (diff < 60) return 'À l instant';
if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`;
if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`;
return `il y a ${Math.floor(diff / 86400)} j`;
@@ -1129,7 +1129,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Listen for page navigation
const originalNavigateTo = window.navigateTo;
if (originalNavigateTo) {
window.navigateTo = function(pageName) {
window.navigateTo = function (pageName) {
originalNavigateTo(pageName);
if (pageName === 'docker-containers') {
containersPage.init();
+9 -3
View File
@@ -60,14 +60,14 @@ class Settings(BaseSettings):
ssh_remote_user: str = Field(default_factory=lambda: os.environ.get("SSH_REMOTE_USER", "root"))
# === API ===
api_key: str = Field(default_factory=lambda: os.environ.get("API_KEY", "dev-key-12345"))
api_key: str = Field(default_factory=lambda: os.environ.get("API_KEY", ""))
api_title: str = "Homelab Automation Dashboard API"
api_version: str = "1.0.0"
api_description: str = "API REST moderne pour la gestion automatique d'homelab"
# === JWT Authentication ===
jwt_secret_key: str = Field(
default_factory=lambda: os.environ.get("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
default_factory=lambda: os.environ.get("JWT_SECRET_KEY", "")
)
jwt_expire_minutes: int = Field(
default_factory=lambda: int(os.environ.get("JWT_EXPIRE_MINUTES", "1440"))
@@ -85,7 +85,13 @@ class Settings(BaseSettings):
return f"sqlite+aiosqlite:///{self.db_path}"
# === CORS ===
cors_origins: list = Field(default=["*"])
cors_origins: list = Field(
default_factory=lambda: [
o.strip() for o in os.environ.get(
"CORS_ORIGINS", "http://localhost:3000,http://localhost:8008"
).split(",") if o.strip()
]
)
cors_allow_credentials: bool = True
cors_allow_methods: list = Field(default=["*"])
cors_allow_headers: list = Field(default=["*"])
+2 -3
View File
@@ -157,9 +157,8 @@ async def require_admin(
if user.get("type") == "api_key":
return user
# Vérifier le rôle dans le payload JWT
payload = user.get("payload", {})
role = payload.get("role", "viewer")
# Vérifier le rôle directement dans le dict utilisateur (pas dans "payload")
role = user.get("role", "viewer")
if role != "admin":
raise HTTPException(
+10 -3
View File
@@ -5,11 +5,13 @@ Ce module contient la fonction create_app() qui configure et retourne
une instance FastAPI prête à l'emploi.
"""
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from fastapi.responses import FileResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from app.core.config import settings
from app.models.database import init_db, async_session_maker
@@ -40,6 +42,11 @@ def create_app() -> FastAPI:
allow_headers=settings.cors_allow_headers,
)
# Rate limiting
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Monter les fichiers statiques (main.js, etc.)
app.mount("/static", StaticFiles(directory=settings.base_dir, html=False), name="static")
+1248 -1249
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -19,8 +19,11 @@ aiosqlite>=0.19.0
pytest>=7.0.0
pytest-asyncio>=0.21.0
# Authentication
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
PyJWT>=2.8.0
bcrypt>=4.0.0
reportlab>=4.0.0
pillow>=10.0.0
asyncssh>=2.14.0
asyncssh>=2.14.0
slowapi>=0.1.9
cachetools>=5.3.0
jinja2>=3.1.0
+10 -1
View File
@@ -5,8 +5,10 @@ Routes API pour l'authentification JWT.
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db, get_current_user, get_current_user_optional
@@ -17,6 +19,7 @@ from app.schemas.auth import (
from app.services import auth_service
router = APIRouter()
limiter = Limiter(key_func=get_remote_address)
@router.get("/status")
@@ -55,7 +58,9 @@ async def auth_status(
@router.post("/setup")
@limiter.limit("3/minute")
async def setup_admin(
request: Request,
user_data: UserCreate,
db_session: AsyncSession = Depends(get_db)
):
@@ -93,7 +98,9 @@ async def setup_admin(
@router.post("/login", response_model=Token)
@limiter.limit("5/minute")
async def login_form(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db_session: AsyncSession = Depends(get_db)
):
@@ -129,7 +136,9 @@ async def login_form(
@router.post("/login/json", response_model=Token)
@limiter.limit("5/minute")
async def login_json(
request: Request,
credentials: LoginRequest,
db_session: AsyncSession = Depends(get_db)
):
+147 -1085
View File
File diff suppressed because it is too large Load Diff
+30 -14
View File
@@ -7,10 +7,33 @@ from typing import Optional
from pydantic import BaseModel, EmailStr, Field, field_validator
def _validate_password_strength(password: str) -> str:
"""Shared password strength validator.
Requirements:
- Minimum 8 characters
- At least 1 uppercase letter
- At least 1 lowercase letter
- At least 1 digit
- At least 1 special character
"""
if len(password) < 8:
raise ValueError("Le mot de passe doit contenir au moins 8 caractères")
if not any(c.isupper() for c in password):
raise ValueError("Le mot de passe doit contenir au moins une majuscule")
if not any(c.islower() for c in password):
raise ValueError("Le mot de passe doit contenir au moins une minuscule")
if not any(c.isdigit() for c in password):
raise ValueError("Le mot de passe doit contenir au moins un chiffre")
if not any(c in "!@#$%^&*()_+-=[]{}|;:',.<>?/~`" for c in password):
raise ValueError("Le mot de passe doit contenir au moins un caractère spécial")
return password
class LoginRequest(BaseModel):
"""Request schema for user login."""
username: str = Field(..., min_length=3, max_length=50, description="Username")
password: str = Field(..., min_length=6, description="Password")
password: str = Field(..., min_length=1, description="Password")
class Token(BaseModel):
@@ -39,15 +62,12 @@ class UserBase(BaseModel):
class UserCreate(UserBase):
"""Schema for creating a new user."""
password: str = Field(..., min_length=6, max_length=128, description="Password (min 6 chars)")
password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 chars, requires uppercase, lowercase, digit, special char)")
@field_validator('password')
@classmethod
def password_strength(cls, v: str) -> str:
"""Validate password has minimum complexity."""
if len(v) < 6:
raise ValueError('Password must be at least 6 characters')
return v
return _validate_password_strength(v)
class UserUpdate(BaseModel):
@@ -61,14 +81,12 @@ class UserUpdate(BaseModel):
class PasswordChange(BaseModel):
"""Schema for changing password."""
current_password: str = Field(..., description="Current password")
new_password: str = Field(..., min_length=6, max_length=128, description="New password")
new_password: str = Field(..., min_length=8, max_length=128, description="New password")
@field_validator('new_password')
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 6:
raise ValueError('Password must be at least 6 characters')
return v
return _validate_password_strength(v)
class UserOut(BaseModel):
@@ -90,16 +108,14 @@ class UserOut(BaseModel):
class UserSetup(BaseModel):
"""Schema for initial admin setup (first user creation)."""
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6, max_length=128)
password: str = Field(..., min_length=8, max_length=128)
email: Optional[EmailStr] = None
display_name: Optional[str] = None
@field_validator('password')
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 6:
raise ValueError('Password must be at least 6 characters')
return v
return _validate_password_strength(v)
class AuthStatus(BaseModel):
+17 -24
View File
@@ -12,6 +12,7 @@ from time import perf_counter
from typing import Any, Dict, List, Optional
import yaml
from cachetools import TTLCache
from app.core.config import settings
from app.schemas.host_api import AnsibleInventoryHost
@@ -28,27 +29,23 @@ class AnsibleService:
self.ssh_key_path = ssh_key_path or settings.ssh_key_path
self.ssh_user = ssh_user or settings.ssh_user
# Cache
self._inventory_cache: Optional[Dict] = None
self._inventory_cache_time: float = 0
self._playbooks_cache: Optional[List[PlaybookInfo]] = None
self._playbooks_cache_time: float = 0
self._cache_ttl = settings.inventory_cache_ttl
# Cache with automatic TTL eviction (replaces hand-rolled cache)
cache_ttl = settings.inventory_cache_ttl
self._inventory_cache: TTLCache = TTLCache(maxsize=16, ttl=cache_ttl)
self._playbooks_cache: TTLCache = TTLCache(maxsize=16, ttl=cache_ttl)
def invalidate_cache(self):
"""Invalide les caches."""
self._inventory_cache = None
self._playbooks_cache = None
self._inventory_cache.clear()
self._playbooks_cache.clear()
# ===== PLAYBOOKS =====
def get_playbooks(self) -> List[Dict[str, Any]]:
"""Récupère la liste des playbooks disponibles."""
import time
current_time = time.time()
if self._playbooks_cache and (current_time - self._playbooks_cache_time) < self._cache_ttl:
return self._playbooks_cache
cache_key = "all"
if cache_key in self._playbooks_cache:
return self._playbooks_cache[cache_key]
playbooks = []
@@ -78,8 +75,7 @@ class AnsibleService:
if pb:
playbooks.append(pb)
self._playbooks_cache = playbooks
self._playbooks_cache_time = current_time
self._playbooks_cache[cache_key] = playbooks
return playbooks
def _parse_playbook_file(self, file_path: Path, category: str, subcategory: str) -> Optional[Dict[str, Any]]:
@@ -179,11 +175,9 @@ class AnsibleService:
def load_inventory(self) -> Dict:
"""Charge l'inventaire Ansible depuis le fichier YAML."""
import time
current_time = time.time()
if self._inventory_cache and (current_time - self._inventory_cache_time) < self._cache_ttl:
return self._inventory_cache
cache_key = "inventory"
if cache_key in self._inventory_cache:
return self._inventory_cache[cache_key]
if not self.inventory_path.exists():
return {}
@@ -191,8 +185,7 @@ class AnsibleService:
try:
with open(self.inventory_path, 'r', encoding='utf-8') as f:
inventory = yaml.safe_load(f) or {}
self._inventory_cache = inventory
self._inventory_cache_time = current_time
self._inventory_cache[cache_key] = inventory
return inventory
except Exception:
return {}
@@ -204,8 +197,8 @@ class AnsibleService:
with open(self.inventory_path, 'w', encoding='utf-8') as f:
yaml.dump(inventory, f, default_flow_style=False, allow_unicode=True)
# Invalider le cache
self._inventory_cache = None
# Invalider le cache inventaire
self._inventory_cache.clear()
def get_hosts_from_inventory(self, group_filter: str = None) -> List[AnsibleInventoryHost]:
"""Récupère les hôtes depuis l'inventaire Ansible."""
+7 -6
View File
@@ -11,15 +11,16 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
from jose import JWTError, jwt
import jwt
from app.core.config import settings
from app.models.user import User
from app.schemas.auth import TokenData
# Configuration from environment variables
SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "homelab-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("JWT_EXPIRE_MINUTES", "1440")) # 24 hours default
# Configuration from centralized settings (single source of truth)
SECRET_KEY = settings.jwt_secret_key
ALGORITHM = settings.jwt_algorithm
ACCESS_TOKEN_EXPIRE_MINUTES = settings.jwt_expire_minutes
class AuthService:
@@ -83,7 +84,7 @@ class AuthService:
return None
return TokenData(username=username, user_id=user_id, role=role)
except JWTError:
except (jwt.PyJWTError, jwt.ExpiredSignatureError, jwt.DecodeError):
return None
@staticmethod
+325
View File
@@ -0,0 +1,325 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ safe_title }}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0f0f1a;
color: #e5e7eb;
height: 100vh;
display: flex;
flex-direction: column;
}
.terminal-header {
background: #1a1a2e;
padding: 0.5rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #374151;
flex-shrink: 0;
}
.terminal-header .host-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.terminal-header .host-name { font-weight: 600; color: #fff; }
.terminal-header .host-ip { color: #9ca3af; font-size: 0.875rem; }
.terminal-header .status-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.terminal-header .status-badge.online {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
.terminal-header .session-timer {
display: flex;
align-items: center;
gap: 0.5rem;
color: #9ca3af;
font-size: 0.875rem;
}
.terminal-header .session-timer.warning { color: #fbbf24; }
.terminal-header .session-timer.critical { color: #ef4444; }
.terminal-header .actions { display: flex; gap: 0.5rem; }
.terminal-header .btn {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.terminal-header .btn-secondary { background: #374151; color: #e5e7eb; }
.terminal-header .btn-secondary:hover { background: #4b5563; }
.terminal-header .btn-secondary.active { background: #4f46e5; color: #fff; }
.terminal-header .btn-danger { background: #dc2626; color: #fff; }
.terminal-header .btn-danger:hover { background: #b91c1c; }
.terminal-container { flex: 1; position: relative; overflow: hidden; }
.terminal-container iframe { width: 100%; height: 100%; border: none; background: #000; }
.terminal-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #0f0f1a;
}
.terminal-loading .spinner {
width: 48px; height: 48px;
border: 3px solid #374151;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.terminal-loading .loading-text { margin-top: 1rem; color: #9ca3af; }
.pwa-hint {
background: #1e3a5f;
color: #93c5fd;
padding: 0.5rem 1rem;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.pwa-hint button {
background: none; border: none; color: #60a5fa; cursor: pointer; font-size: 0.75rem;
}
body.embed #pwaHint { display: none !important; }
body.embed .terminal-header { display: none !important; }
.hidden { display: none !important; }
/* History Panel */
.terminal-history-panel {
position: absolute; top: 0; left: 0; right: 0;
max-height: 0; overflow: hidden;
background: #1e1e2e; border-bottom: 1px solid #374151;
display: flex; flex-direction: column; z-index: 50;
box-shadow: 0 5px 25px rgba(0,0,0,0.5);
opacity: 0;
transition: max-height 0.3s ease, opacity 0.2s ease;
}
.terminal-history-panel.open { max-height: 350px; opacity: 1; }
.terminal-history-header {
padding: 0.75rem; background: #151520; border-bottom: 1px solid #374151; flex-shrink: 0;
}
.terminal-history-search {
position: relative; display: flex; align-items: center;
background: #0f0f1a; border: 1px solid #374151;
border-radius: 0.375rem; padding: 0.5rem 0.75rem; transition: border-color 0.15s;
}
.terminal-history-search:focus-within { border-color: #7c3aed; }
.terminal-history-search i { color: #6b7280; font-size: 0.875rem; margin-right: 0.5rem; }
.terminal-history-search input {
flex: 1; background: transparent; border: none; color: #e5e7eb;
font-size: 0.875rem; outline: none;
}
.terminal-history-search input::placeholder { color: #6b7280; }
.terminal-history-clear-search {
background: none; border: none; color: #6b7280; cursor: pointer;
padding: 0.25rem; font-size: 0.75rem; transition: color 0.15s;
}
.terminal-history-clear-search:hover { color: #e5e7eb; }
.terminal-history-filters {
display: flex; align-items: center; gap: 0.75rem; margin-top: 0.5rem;
}
.terminal-history-filter-select {
background: #0f0f1a; border: 1px solid #374151; border-radius: 0.375rem;
color: #e5e7eb; padding: 0.375rem 0.5rem; font-size: 0.75rem;
cursor: pointer; outline: none;
}
.terminal-history-filter-select:hover,
.terminal-history-filter-select:focus { border-color: #7c3aed; }
.terminal-history-scope {
display: flex; align-items: center; gap: 0.375rem;
font-size: 0.75rem; color: #9ca3af; cursor: pointer;
}
.terminal-history-scope input {
width: 0.875rem; height: 0.875rem; accent-color: #7c3aed; cursor: pointer;
}
.terminal-history-list {
flex: 1; overflow-y: auto; padding: 0.5rem;
scrollbar-width: thin; scrollbar-color: #374151 transparent;
}
.terminal-history-list::-webkit-scrollbar { width: 6px; }
.terminal-history-list::-webkit-scrollbar-track { background: transparent; }
.terminal-history-list::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
.terminal-history-item {
display: flex; align-items: center; padding: 0.5rem 0.75rem;
border-radius: 0.375rem; cursor: pointer; transition: background 0.15s;
gap: 0.75rem; border: 1px solid transparent;
}
.terminal-history-item:hover { background: rgba(124, 58, 237, 0.1); }
.terminal-history-item.selected {
background: rgba(124, 58, 237, 0.2); border-color: rgba(124, 58, 237, 0.4);
}
.terminal-history-cmd { flex: 1; min-width: 0; }
.terminal-history-cmd code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem; color: #a5b4fc;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;
}
.terminal-history-cmd code mark {
background: rgba(251, 191, 36, 0.3); color: #fbbf24;
padding: 0 0.125rem; border-radius: 2px;
}
.terminal-history-meta { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.terminal-history-time { font-size: 0.6875rem; color: #6b7280; }
.terminal-history-count {
font-size: 0.625rem; color: #7c3aed;
background: rgba(124, 58, 237, 0.2); padding: 0.125rem 0.375rem;
border-radius: 9999px; font-weight: 500;
}
.terminal-history-actions-inline {
display: flex; gap: 0.25rem; opacity: 0; transition: opacity 0.15s;
}
.terminal-history-item:hover .terminal-history-actions-inline,
.terminal-history-item.selected .terminal-history-actions-inline { opacity: 1; }
.terminal-history-action {
background: rgba(55, 65, 81, 0.5); border: none; color: #9ca3af;
padding: 0.375rem; border-radius: 0.25rem; cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; transition: background 0.15s, color 0.15s, transform 0.1s;
}
.terminal-history-action:hover {
background: rgba(124, 58, 237, 0.3); color: #fff; transform: scale(1.1);
}
.terminal-history-action-execute:hover {
background: rgba(34, 197, 94, 0.3); color: #4ade80;
}
.terminal-history-empty {
display: flex; flex-direction: column; align-items: center;
justify-content: center; padding: 2rem; color: #6b7280; gap: 0.5rem; text-align: center;
}
.terminal-history-empty i { font-size: 2rem; opacity: 0.5; }
.terminal-history-loading {
display: flex; align-items: center; justify-content: center;
padding: 2rem; color: #9ca3af; gap: 0.5rem;
}
.terminal-history-footer {
padding: 0.5rem 1rem; background: #151520;
border-top: 1px solid #374151; flex-shrink: 0;
}
.terminal-history-hint {
font-size: 0.6875rem; color: #6b7280;
display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap;
}
.terminal-history-hint kbd {
background: #374151; color: #e5e7eb; padding: 0.125rem 0.375rem;
border-radius: 0.25rem; font-family: inherit; font-size: 0.625rem; border: 1px solid #4b5563;
}
</style>
</head>
<body class="{{ 'embed' if embed_mode else '' }}">
<div class="pwa-hint" id="pwaHint">
<span>💡 Pour une expérience sans barre d'outils : installez en PWA ou utilisez <code>chrome --app=URL</code></span>
<button onclick="dismissPwaHint()">✕ Fermer</button>
</div>
<div class="terminal-header">
<div class="host-info">
<span class="host-name">{{ safe_host_name }}</span>
<span class="host-ip">{{ safe_host_ip }}</span>
<span class="status-badge online">Connecté</span>
</div>
<div class="session-timer" id="sessionTimer">
<i class="fas fa-clock"></i>
<span id="timerDisplay">{{ timer_display }}</span>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="toggleHistory()" id="btnHistory" title="Historique (Ctrl+R)">
<i class="fas fa-history"></i> Historique
</button>
<button class="btn btn-secondary" onclick="copySSHCommand()" title="Copier la commande SSH">
<i class="fas fa-copy"></i> SSH
</button>
<button class="btn btn-secondary" onclick="reconnect()" title="Reconnecter">
<i class="fas fa-redo"></i> Reconnecter
</button>
<button class="btn btn-secondary" onclick="goToDashboard()" title="Retour au Dashboard">
<i class="fas fa-arrow-left"></i> Dashboard
</button>
<button class="btn btn-danger" onclick="closeSession()" title="Fermer la session">
<i class="fas fa-times"></i> Fermer
</button>
</div>
</div>
<div class="terminal-container">
<div class="terminal-loading" id="terminalLoading">
<div style="text-align: center;">
<div class="spinner"></div>
<div class="loading-text">Connexion au terminal...</div>
<div style="margin-top:0.75rem; font-size:0.85rem; color:#9ca3af;">
session={{ session_id_short }}… · ttyd_port={{ ttyd_port }} · Debug: Ctrl+Shift+D
</div>
</div>
</div>
<noscript>
<div style="position:absolute; top:14px; right:14px; z-index:9999; background:rgba(239,68,68,0.18); border:1px solid rgba(239,68,68,0.6); color:#fff; padding:10px 12px; border-radius:10px; font-size:12px; max-width:520px;">
JavaScript est désactivé: le terminal web ne peut pas se charger.
</div>
</noscript>
{% if debug_panel_html %}
{{ debug_panel_html | safe }}
{% endif %}
<iframe
id="terminalFrame"
src="about:blank"
allow="clipboard-read; clipboard-write; clipboard-write-text"
></iframe>
<div class="terminal-history-panel" id="terminalHistoryPanel" style="display: none;">
<div class="terminal-history-header">
<div class="terminal-history-search">
<i class="fas fa-search"></i>
<input type="text"
id="terminalHistorySearch"
placeholder="Rechercher... (Ctrl+R)"
oninput="searchHistory(this.value)"
onkeydown="handleHistoryKeydown(event)"
autocomplete="off">
<button class="terminal-history-clear-search" onclick="clearHistorySearch()" title="Effacer">
<i class="fas fa-times"></i>
</button>
</div>
<div class="terminal-history-filters">
<select id="terminalHistoryTimeFilter" onchange="setHistoryTimeFilter(this.value)" class="terminal-history-filter-select">
<option value="all">Tout</option>
<option value="today">Aujourd'hui</option>
<option value="week">7 jours</option>
<option value="month">30 jours</option>
</select>
<label class="terminal-history-scope">
<input type="checkbox" id="terminalHistoryAllHosts" onchange="toggleHistoryScope()">
<span>Tous hôtes</span>
</label>
</div>
</div>
<div class="terminal-history-list" id="terminalHistoryList">
<div class="terminal-history-loading">
<i class="fas fa-spinner fa-spin"></i> Chargement...
</div>
</div>
<div class="terminal-history-footer">
<span class="terminal-history-hint">
<kbd>↑</kbd><kbd>↓</kbd> naviguer · <kbd>Enter</kbd> insérer · <kbd>Esc</kbd> fermer
</span>
</div>
</div>
</div>
{{ script_block | safe }}
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<style>
body {
font-family: system-ui, sans-serif;
background: #1a1a2e;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.error {
text-align: center;
padding: 2rem;
max-width: 520px;
}
.error h1 { color: #ef4444; }
.error p { color: #e5e7eb; margin-bottom: 0.75rem; }
.error a { color: #60a5fa; }
.btn {
display: inline-block;
margin-top: 1rem;
padding: 0.5rem 0.9rem;
background: #7c3aed;
color: #fff;
text-decoration: none;
border-radius: 0.5rem;
}
</style>
</head>
<body>
<div class="error">
<h1>{{ heading }}</h1>
{% for msg in messages %}
<p>{{ msg }}</p>
{% endfor %}
{% if show_back_link %}
<a class="btn" href="/">Retour au Dashboard</a>
{% endif %}
</div>
</body>
</html>