fix: P0 critical runtime issues
1. conftest.py: add missing `from server.main import app` import 2. dependencies.py: get_db() reuses middleware session instead of creating independent AsyncSessionLocal (fixes double-session bug) 3. auth_jwt.py: get_optional_admin uses request.state.db instead of creating leaked AsyncSessionLocal session 4. config.py: default DATABASE_URL uses mysql+aiomysql (not pymysql) 5. sync_engine_v2.py: sanitize config keys with regex, escape values to prevent command injection in sync_config 6. sync_v2.py: add POST /api/sync/browse endpoint for remote dir listing 7. files.html: browseDir calls real API instead of placeholder stub Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -106,15 +106,15 @@ async def _verify_token(token: str, request: Request) -> Optional[Admin]:
|
||||
|
||||
|
||||
async def get_optional_admin(
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
) -> Optional[Admin]:
|
||||
"""Optional JWT auth — returns Admin if token valid, None otherwise.
|
||||
Used for endpoints that work with or without auth (e.g., public data with optional user context).
|
||||
Uses middleware session via request.state.db (no leaked sessions).
|
||||
"""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
# Can't use _verify_token without request here, so do inline
|
||||
try:
|
||||
import jwt as pyjwt
|
||||
payload = pyjwt.decode(credentials.credentials, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
@@ -125,11 +125,11 @@ async def get_optional_admin(
|
||||
admin_id = int(admin_id)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
async with AsyncSessionLocal() as session:
|
||||
admin_repo = AdminRepositoryImpl(session)
|
||||
admin = await admin_repo.get_by_id(admin_id)
|
||||
return admin if admin and admin.is_active else None
|
||||
|
||||
# Use middleware session instead of creating independent session
|
||||
session = await _get_request_session(request)
|
||||
admin_repo = AdminRepositoryImpl(session)
|
||||
admin = await admin_repo.get_by_id(admin_id)
|
||||
return admin if admin and admin.is_active else None
|
||||
except Exception:
|
||||
return None
|
||||
@@ -29,18 +29,23 @@ from server.application.services.auth_service import AuthService
|
||||
from server.application.services.sync_service import SyncService
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Yield an async database session (for use as FastAPI Depends)
|
||||
async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Yield DB session from middleware (request.state.db).
|
||||
|
||||
For routes that use get_db() directly (settings, schedules, etc.),
|
||||
we still use the yield pattern which properly closes the session.
|
||||
These routes don't go through the service factories.
|
||||
DbSessionMiddleware ensures every /api/ request has a session.
|
||||
This dependency simply yields it so routes can use Depends(get_db).
|
||||
No double-session: one session per request, shared everywhere.
|
||||
"""
|
||||
session = AsyncSessionLocal()
|
||||
try:
|
||||
session = getattr(request.state, "db", None)
|
||||
if session is not None:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
else:
|
||||
# Fallback for tests or edge cases without middleware
|
||||
session = AsyncSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def _get_request_session(request: Request) -> AsyncSession:
|
||||
|
||||
+51
-1
@@ -112,4 +112,54 @@ async def sftp_transfer(
|
||||
local_path=payload["local_path"],
|
||||
remote_path=payload["remote_path"],
|
||||
operator=admin.username,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/browse")
|
||||
async def browse_directory(
|
||||
payload: dict,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Browse remote directory listing via SSH"""
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
|
||||
session = request.state.db
|
||||
server_id = payload.get("server_id")
|
||||
path = payload.get("path", "/")
|
||||
|
||||
if not server_id:
|
||||
raise HTTPException(status_code=400, detail="server_id required")
|
||||
|
||||
repo = ServerRepositoryImpl(session)
|
||||
server = await repo.get_by_id(server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
# Use ls -la for detailed listing, parse output
|
||||
result = await exec_ssh_command(server, f"ls -la {path}", timeout=10)
|
||||
if result["exit_code"] != 0:
|
||||
return {"path": path, "entries": [], "error": result["stderr"][:500]}
|
||||
|
||||
entries = []
|
||||
for line in result["stdout"].split("\n")[1:]: # Skip "total" line
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split(None, 8)
|
||||
if len(parts) >= 9:
|
||||
perms, _, owner, group, size, *date_parts, name = (
|
||||
parts[0], parts[1], parts[2], parts[3], parts[4],
|
||||
parts[5:-1], parts[-1]
|
||||
)
|
||||
is_dir = parts[0].startswith("d")
|
||||
entries.append({
|
||||
"name": parts[-1],
|
||||
"is_dir": is_dir,
|
||||
"size": parts[4],
|
||||
"perms": parts[0],
|
||||
"owner": parts[2],
|
||||
"modified": " ".join(parts[5:8]),
|
||||
})
|
||||
|
||||
return {"path": path, "entries": entries}
|
||||
@@ -201,12 +201,17 @@ class SyncEngineV2:
|
||||
Config updates are applied as shell commands:
|
||||
- `sysctl` for kernel params
|
||||
- `echo` for /etc/sysctl.conf entries
|
||||
- Custom script for other config types
|
||||
"""
|
||||
import re
|
||||
commands = []
|
||||
for key, value in config_updates.items():
|
||||
commands.append(f"sysctl -w {key}={value}")
|
||||
commands.append(f"echo '{key}={value}' >> /etc/sysctl.d/99-nexus.conf")
|
||||
# Sanitize: only allow alphanumeric/dot/underscore/dash keys and safe values
|
||||
if not re.match(r'^[a-zA-Z0-9._-]+$', str(key)):
|
||||
logger.warning(f"Skipping invalid config key: {key!r}")
|
||||
continue
|
||||
safe_value = str(value).replace("'", "'\\''")
|
||||
commands.append(f"sysctl -w {key}={safe_value}")
|
||||
commands.append(f"echo '{key}={safe_value}' >> /etc/sysctl.d/99-nexus.conf")
|
||||
|
||||
return await self.sync_commands(
|
||||
server_ids=server_ids,
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class Settings(BaseSettings):
|
||||
PORT: int = 8600
|
||||
|
||||
# Database (immutable — must be set in .env)
|
||||
DATABASE_URL: str = "mysql+pymysql://root:password@127.0.0.1:3306/nexus"
|
||||
DATABASE_URL: str = "mysql+aiomysql://root:password@127.0.0.1:3306/nexus"
|
||||
DB_POOL_SIZE: Optional[int] = 100
|
||||
DB_MAX_OVERFLOW: Optional[int] = 100
|
||||
|
||||
|
||||
+8
-5
@@ -1,6 +1,6 @@
|
||||
"""Nexus — Test Configuration (pytest + async fixtures)
|
||||
|
||||
Fixed from original: wired up dependency_overrides properly,
|
||||
Fixed: wired up dependency_overrides properly,
|
||||
added test Redis mock, added admin fixture for JWT auth tests.
|
||||
"""
|
||||
|
||||
@@ -47,6 +47,7 @@ async def db_session(test_engine):
|
||||
@pytest.fixture(scope="function")
|
||||
async def api_client(db_session):
|
||||
"""Create an async HTTP client for API integration tests"""
|
||||
from server.main import app
|
||||
from server.api.dependencies import get_db
|
||||
|
||||
async def override_get_session():
|
||||
@@ -67,9 +68,12 @@ async def test_admin(db_session):
|
||||
import bcrypt
|
||||
from server.application.services.auth_service import AuthService
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
from server.infrastructure.database.login_attempt_repo import LoginAttemptRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
|
||||
# LoginAttemptRepositoryImpl requires login_attempt_repo which may not exist in test DB
|
||||
# Use a simple mock approach
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
password_hash = bcrypt.hashpw("test123".encode(), bcrypt.gensalt()).decode()
|
||||
admin = Admin(
|
||||
username="testadmin",
|
||||
@@ -81,12 +85,11 @@ async def test_admin(db_session):
|
||||
await db_session.commit()
|
||||
await db_session.refresh(admin)
|
||||
|
||||
# Generate JWT token for this admin
|
||||
auth_service = AuthService(
|
||||
admin_repo=AdminRepositoryImpl(db_session),
|
||||
attempt_repo=LoginAttemptRepositoryImpl(db_session),
|
||||
attempt_repo=MagicMock(),
|
||||
audit_repo=AuditLogRepositoryImpl(db_session),
|
||||
)
|
||||
token = auth_service._create_access_token(admin)
|
||||
|
||||
return {"admin": admin, "token": token}
|
||||
return {"admin": admin, "token": token}
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@
|
||||
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
|
||||
function ah(){return{'Authorization':'Bearer '+token}}
|
||||
function doLogout(){localStorage.clear();location.href='/app/login.html'}
|
||||
async function loadServers(){const r=await fetch(API+'/servers/',{headers:ah()});const servers=await r.json();document.getElementById('serverSelect').innerHTML='<option>-- 选择服务器 --</option>'+servers.map(s=>`<option value="${s.id}">${s.name} (${s.domain})</option>`).join('')}
|
||||
function browseDir(){const sid=document.getElementById('serverSelect').value;const dir=document.getElementById('dirPath').value||'/www/wwwroot/';document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-slate-500">📂 浏览 '+dir+'</div>'}
|
||||
async function loadServers(){const r=await fetch(API+'/servers/',{headers:ah()});const servers=await r.json();document.getElementById('serverSelect').innerHTML='<option value="">-- 选择服务器 --</option>'+servers.map(s=>`<option value="${s.id}">${s.name} (${s.domain})</option>`).join('')}
|
||||
async function browseDir(path){if(path){document.getElementById('dirPath').value=path}const sid=document.getElementById('serverSelect').value;const dir=document.getElementById('dirPath').value||'/www/wwwroot/';if(!sid){alert('请先选择服务器');return}document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-slate-500">加载中...</div>';try{const r=await fetch(API+'/sync/browse',{method:'POST',headers:{...ah(),'Content-Type':'application/json'},body:JSON.stringify({server_id:parseInt(sid),path:dir})});const d=await r.json();if(d.error){document.getElementById('fileList').innerHTML=`<div class="px-4 py-8 text-center text-red-400">${esc(d.error)}</div>`;return}document.getElementById('fileList').innerHTML=d.entries.length?d.entries.map(e=>`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 ${e.is_dir?'cursor-pointer':''}" ${e.is_dir?`onclick="browseDir('${(dir.replace(/\/$/,'')+'/'+e.name).replace(/'/g,"\\'")}')"`:''}"><span class="${e.is_dir?'text-yellow-400':'text-slate-400'}">${e.is_dir?'📁':'📄'}</span><span class="text-white text-sm flex-1">${esc(e.name)}</span><span class="text-slate-500 text-xs">${e.size}</span><span class="text-slate-600 text-xs">${e.perms}</span></div>`).join(''):'<div class="px-4 py-8 text-center text-slate-500">空目录</div>'}catch(e){document.getElementById('fileList').innerHTML=`<div class="px-4 py-8 text-center text-red-400">连接失败: ${esc(e.message)}</div>`}}
|
||||
loadServers();
|
||||
</script>
|
||||
</body></html>
|
||||
Reference in New Issue
Block a user