Files
Nexus/server/api/settings.py
T
Your Name d414b945e1 feat: audit log time-range filter + script execution schedules
Audit log time-range filter:
- audit_log_repo.py: unified query() with action/admin_username/date_from/date_to
  filters + total count; _build_filters() helper for date parsing
- settings.py GET /audit/: add date_from, date_to, admin_username query params
- audit.html: date range inputs (YYYY-MM-DD), admin filter input, action filter
  expanded with optgroups (服务器/推送/脚本/认证/设置); clear filter button

Script execution schedules:
- domain/models: PushSchedule extended with schedule_type('push'|'script'),
  script_id FK, script_content Text, exec_timeout int, long_task bool;
  source_path made nullable
- migrations.py: 6 new ALTER TABLE statements (idempotent)
- schedule_runner.py: detect schedule_type; for 'script' type use ScriptService;
  for 'push' type keep original SyncEngineV2 sync_files behaviour
- schemas.py: ScheduleCreate/Update add all new fields
- schedules.html: full UI rewrite
  - type selector radio: 📁 文件推送 /  脚本执行
  - script fields: script library dropdown + textarea + timeout + long_task
  - schedule list shows type badge + detail preview
  - saveSched() validates required fields per type

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 17:29:16 +08:00

440 lines
14 KiB
Python

"""Nexus — Settings & Schedules API Routes
Presentation layer — system settings + push schedules + password presets + audit logs.
Security: All write operations require JWT authentication.
Immutable settings (secret_key, api_key, encryption_key, database_url) cannot be modified via API.
Sensitive values are masked in GET responses.
"""
from typing import Optional
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from server.api.dependencies import get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import ScheduleCreate, ScheduleUpdate, PresetCreate, SettingUpdatePayload
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, AuditLog, Admin
from sqlalchemy.ext.asyncio import AsyncSession
# Settings that cannot be modified via API (encryption consistency)
IMMUTABLE_KEYS = {"secret_key", "api_key", "encryption_key", "database_url"}
# Settings whose values are masked in GET responses
SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key"}
router = APIRouter(prefix="/api/settings", tags=["settings"])
class ApiKeyRevealRequest(BaseModel):
current_password: str = Field(..., min_length=1, max_length=255)
# ── System Settings ──
@router.get("/", response_model=list)
async def list_settings(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all system settings (sensitive values masked)"""
repo = SettingRepositoryImpl(db)
settings = await repo.get_all()
result = []
for s in settings:
val = s.value
if s.key in SENSITIVE_KEYS and val:
val = val[:8] + "..." if len(val) > 8 else "***"
result.append({"key": s.key, "value": val, "updated_at": str(s.updated_at)})
return result
@router.get("/{key}", response_model=dict)
async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""Get a single setting by key (sensitive values masked)"""
repo = SettingRepositoryImpl(db)
value = await repo.get(key)
if value is None:
raise HTTPException(status_code=404, detail="Setting not found")
if key in SENSITIVE_KEYS and value:
value = value[:8] + "..." if len(value) > 8 else "***"
return {"key": key, "value": value}
@router.post("/api-key/reveal", response_model=dict)
async def reveal_api_key(
payload: ApiKeyRevealRequest,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Reveal global API_KEY after re-entering account password."""
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
admin_repo = AdminRepositoryImpl(db)
current = await admin_repo.get_by_id(admin.id)
if not current or not bcrypt.checkpw(
payload.current_password.encode(),
current.password_hash.encode(),
):
raise HTTPException(status_code=400, detail="当前密码错误")
repo = SettingRepositoryImpl(db)
value = await repo.get("api_key")
if value is None:
raise HTTPException(status_code=404, detail="API_KEY not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="reveal_api_key",
target_type="setting",
detail="Global API key revealed",
ip_address=request.client.host if request.client else "",
))
return {"key": "api_key", "value": value}
@router.put("/{key}", response_model=dict)
async def set_setting(
key: str,
payload: SettingUpdatePayload,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Set a system setting value (immutable keys are rejected)"""
if key in IMMUTABLE_KEYS:
raise HTTPException(status_code=403, detail=f"Setting '{key}' is immutable and cannot be modified via API")
repo = SettingRepositoryImpl(db)
setting = await repo.set(key, str(payload.value))
# Audit log
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="update_setting",
target_type="setting",
detail=f"key={key}",
ip_address=request.client.host if request.client else "",
))
return {"key": setting.key, "value": setting.value}
# ── Push Schedules ──
schedule_router = APIRouter(prefix="/api/schedules", tags=["schedules"])
@schedule_router.get("/", response_model=list)
async def list_schedules(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all push schedules"""
repo = PushScheduleRepositoryImpl(db)
schedules = await repo.get_all()
return [_schedule_to_dict(s) for s in schedules]
@schedule_router.post("/", response_model=dict, status_code=201)
async def create_schedule(
payload: ScheduleCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a new push schedule"""
repo = PushScheduleRepositoryImpl(db)
schedule = PushSchedule(**payload.model_dump())
created = await repo.create(schedule)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="create_schedule",
target_type="schedule",
target_id=created.id,
detail=f"name={created.name}",
ip_address=request.client.host if request.client else "",
))
return _schedule_to_dict(created)
@schedule_router.put("/{id}", response_model=dict)
async def update_schedule(
id: int,
payload: ScheduleUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a push schedule"""
repo = PushScheduleRepositoryImpl(db)
schedule = await repo.get_by_id(id)
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
for key, value in payload.model_dump(exclude_unset=True).items():
if hasattr(schedule, key) and key != "id":
setattr(schedule, key, value)
updated = await repo.update(schedule)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="update_schedule",
target_type="schedule",
target_id=id,
detail=f"name={updated.name}",
ip_address=request.client.host if request.client else "",
))
return _schedule_to_dict(updated)
@schedule_router.delete("/{id}", status_code=204)
async def delete_schedule(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a push schedule"""
repo = PushScheduleRepositoryImpl(db)
result = await repo.delete(id)
if not result:
raise HTTPException(status_code=404, detail="Schedule not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_schedule",
target_type="schedule",
target_id=id,
ip_address=request.client.host if request.client else "",
))
# ── Password Presets ──
preset_router = APIRouter(prefix="/api/presets", tags=["presets"])
@preset_router.get("/", response_model=list)
async def list_presets(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all password presets"""
repo = PasswordPresetRepositoryImpl(db)
presets = await repo.get_all()
return [{"id": p.id, "name": p.name, "created_at": str(p.created_at)} for p in presets]
@preset_router.post("/", response_model=dict, status_code=201)
async def create_preset(
payload: PresetCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a password preset (password will be encrypted)"""
from server.infrastructure.database.crypto import encrypt_value
repo = PasswordPresetRepositoryImpl(db)
encrypted = encrypt_value(payload.encrypted_pw)
preset = PasswordPreset(name=payload.name, encrypted_pw=encrypted)
created = await repo.create(preset)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="create_preset",
target_type="preset",
target_id=created.id,
detail=f"name={created.name}",
ip_address=request.client.host if request.client else "",
))
return {"id": created.id, "name": created.name}
@preset_router.delete("/{id}", status_code=204)
async def delete_preset(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a password preset"""
repo = PasswordPresetRepositoryImpl(db)
result = await repo.delete(id)
if not result:
raise HTTPException(status_code=404, detail="Preset not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_preset",
target_type="preset",
target_id=id,
ip_address=request.client.host if request.client else "",
))
# ── Audit Logs ──
audit_router = APIRouter(prefix="/api/audit", tags=["audit"])
@audit_router.get("/", response_model=dict)
async def list_audit_logs(
action: Optional[str] = None,
admin_username: Optional[str] = None,
date_from: Optional[str] = None, # ISO date: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS
date_to: Optional[str] = None,
limit: int = 50,
offset: int = 0,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List audit logs with pagination, action filter, admin filter, and date range."""
repo = AuditLogRepositoryImpl(db)
logs, total = await repo.query(
action=action,
admin_username=admin_username,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset,
)
return {
"total": total,
"limit": limit,
"offset": offset,
"items": [_audit_to_dict(log) for log in logs],
}
# ── Retry Jobs ──
retry_router = APIRouter(prefix="/api/retries", tags=["retries"])
@retry_router.get("/", response_model=list)
async def list_retry_jobs(
limit: int = 100,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List all retry jobs (not just pending)"""
repo = PushRetryJobRepositoryImpl(db)
jobs = await repo.get_all(limit)
return [_retry_to_dict(j) for j in jobs]
@retry_router.post("/{id}/retry", response_model=dict)
async def retry_job(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Manually retry a failed/pending job"""
repo = PushRetryJobRepositoryImpl(db)
job = await repo.get_by_id(id)
if not job:
raise HTTPException(status_code=404, detail="Retry job not found")
# Reset the job to pending state for immediate retry
job = await repo.update_status(
id, "pending",
retry_count=0,
next_retry_at=None,
last_error=None,
)
# Audit log
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="retry_job",
target_type="retry",
target_id=id,
detail=f"Manual retry triggered for job #{id}",
ip_address=request.client.host if request.client else "",
))
return _retry_to_dict(job)
@retry_router.delete("/{id}", status_code=204)
async def delete_retry_job(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a retry job"""
repo = PushRetryJobRepositoryImpl(db)
job = await repo.get_by_id(id)
if not job:
raise HTTPException(status_code=404, detail="Retry job not found")
# Audit log
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_retry",
target_type="retry",
target_id=id,
detail=f"Retry job #{id} deleted",
ip_address=request.client.host if request.client else "",
))
await repo.delete(id)
# ── Helper functions ──
def _schedule_to_dict(schedule: PushSchedule) -> dict:
return {
"id": schedule.id,
"name": schedule.name,
"source_path": schedule.source_path,
"server_ids": schedule.server_ids,
"cron_expr": schedule.cron_expr,
"sync_mode": schedule.sync_mode or "incremental",
"enabled": schedule.enabled,
"last_run_at": str(schedule.last_run_at) if schedule.last_run_at else None,
"created_at": str(schedule.created_at) if schedule.created_at else None,
}
def _audit_to_dict(log: AuditLog) -> dict:
return {
"id": log.id,
"admin_username": log.admin_username,
"action": log.action,
"target_type": log.target_type,
"target_id": log.target_id,
"detail": log.detail,
"ip_address": log.ip_address,
"created_at": str(log.created_at) if log.created_at else None,
}
def _retry_to_dict(job: PushRetryJob) -> dict:
return {
"id": job.id,
"server_id": job.server_id,
"server_name": job.server_name,
"operator": job.operator,
"source_path": job.source_path,
"target_path": job.target_path,
"status": job.status,
"retry_count": job.retry_count,
"max_retries": job.max_retries,
"next_retry_at": str(job.next_retry_at) if job.next_retry_at else None,
"last_error": job.last_error,
"created_at": str(job.created_at) if job.created_at else None,
}