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:
Your Name
2026-05-21 22:54:35 +08:00
parent ead4b2580f
commit 73c1776e1e
7 changed files with 92 additions and 29 deletions
+8 -8
View File
@@ -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
+14 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>