8530f0e0d5
P0-1: PHP配置注入防护 — install.py添加_escape_php_string()转义单引号和反斜杠 P0-2: WebSocket JWT校验 — _verify_ws_token()要求exp+sub字段 P1-1: 删除SHA256密码fallback — auth_service.py和auth.py直接import bcrypt P1-3: LIKE通配符转义 — search.py添加_escape_like()并对所有ilike()加escape参数 P1-2: 安全响应头中间件 — main.py添加SecurityHeadersMiddleware注入4个安全头 P0-3: Refresh Token重用检测 — Admin模型添加token_version字段,token格式改为token:admin_id:version Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
116 lines
3.6 KiB
Python
116 lines
3.6 KiB
Python
"""Nexus — Global Search API Route
|
|
Cross-entity search across servers, scripts, credentials, and schedules.
|
|
All operations require JWT authentication.
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
|
|
from server.api.dependencies import get_db
|
|
from server.api.auth_jwt import get_current_admin
|
|
from server.domain.models import Admin, Server, Script, DbCredential, PushSchedule
|
|
|
|
from sqlalchemy import or_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
router = APIRouter(prefix="/api/search", tags=["search"])
|
|
|
|
|
|
def _escape_like(s: str) -> str:
|
|
"""Escape LIKE wildcard characters in user input.
|
|
|
|
Without this, a user searching for ``%`` matches all rows and ``_``
|
|
matches any single character — a form of information disclosure.
|
|
"""
|
|
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
|
|
|
|
@router.get("/", response_model=dict)
|
|
async def global_search(
|
|
q: str = Query(..., min_length=1, max_length=100, description="Search query"),
|
|
admin: Admin = Depends(get_current_admin),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Global search across servers, scripts, credentials, and schedules.
|
|
|
|
Returns top 10 matches per entity type.
|
|
"""
|
|
from sqlalchemy import select
|
|
|
|
pattern = f"%{_escape_like(q)}%"
|
|
results = {"servers": [], "scripts": [], "credentials": [], "schedules": []}
|
|
|
|
# Servers
|
|
try:
|
|
stmt = select(Server).where(
|
|
or_(
|
|
Server.name.ilike(pattern, escape="\\"),
|
|
Server.domain.ilike(pattern, escape="\\"),
|
|
Server.description.ilike(pattern, escape="\\"),
|
|
Server.category.ilike(pattern, escape="\\"),
|
|
)
|
|
).limit(10)
|
|
res = await db.execute(stmt)
|
|
for s in res.scalars().all():
|
|
results["servers"].append({
|
|
"id": s.id, "name": s.name, "domain": s.domain,
|
|
"category": s.category, "is_online": s.is_online,
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
# Scripts
|
|
try:
|
|
stmt = select(Script).where(
|
|
or_(
|
|
Script.name.ilike(pattern, escape="\\"),
|
|
Script.category.ilike(pattern, escape="\\"),
|
|
Script.description.ilike(pattern, escape="\\"),
|
|
)
|
|
).limit(10)
|
|
res = await db.execute(stmt)
|
|
for s in res.scalars().all():
|
|
results["scripts"].append({
|
|
"id": s.id, "name": s.name, "category": s.category,
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
# Credentials
|
|
try:
|
|
stmt = select(DbCredential).where(
|
|
or_(
|
|
DbCredential.name.ilike(pattern, escape="\\"),
|
|
DbCredential.host.ilike(pattern, escape="\\"),
|
|
)
|
|
).limit(10)
|
|
res = await db.execute(stmt)
|
|
for c in res.scalars().all():
|
|
results["credentials"].append({
|
|
"id": c.id, "name": c.name, "db_type": c.db_type, "host": c.host,
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
# Schedules
|
|
try:
|
|
stmt = select(PushSchedule).where(
|
|
or_(
|
|
PushSchedule.name.ilike(pattern, escape="\\"),
|
|
PushSchedule.source_path.ilike(pattern, escape="\\"),
|
|
)
|
|
).limit(10)
|
|
res = await db.execute(stmt)
|
|
for s in res.scalars().all():
|
|
results["schedules"].append({
|
|
"id": s.id, "name": s.name, "cron_expr": s.cron_expr, "enabled": s.enabled,
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
# Total count
|
|
total = sum(len(v) for v in results.values())
|
|
results["total"] = total
|
|
results["query"] = q
|
|
|
|
return results
|