Files
Neo-ZQYY/apps/backend/app/routers/env_config.py

241 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""环境配置 API
提供 3 个端点:
- GET /api/env-config — 读取 .env敏感值掩码
- PUT /api/env-config — 验证并写入 .env
- GET /api/env-config/export — 导出去敏感值的配置文件
所有端点需要 JWT 认证。
敏感键判定:键名中包含 PASSWORD、TOKEN、SECRET、DSN不区分大小写
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
from app.auth.dependencies import CurrentUser, get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/env-config", tags=["环境配置"])
# .env 文件路径:项目根目录
_ENV_PATH = Path(__file__).resolve().parents[3] / ".env"
# 敏感键关键词(不区分大小写)
_SENSITIVE_KEYWORDS = ("PASSWORD", "TOKEN", "SECRET", "DSN")
_MASK = "****"
# ── Pydantic 模型 ────────────────────────────────────────────
class EnvEntry(BaseModel):
"""单条环境变量键值对。"""
key: str
value: str
class EnvConfigResponse(BaseModel):
"""GET 响应:键值对列表。"""
entries: list[EnvEntry]
class EnvConfigUpdateRequest(BaseModel):
"""PUT 请求体:键值对列表。"""
entries: list[EnvEntry]
# ── 工具函数 ─────────────────────────────────────────────────
def _is_sensitive(key: str) -> bool:
"""判断键名是否为敏感键。"""
upper = key.upper()
return any(kw in upper for kw in _SENSITIVE_KEYWORDS)
def _parse_env(content: str) -> list[dict]:
"""解析 .env 文件内容,返回行级结构。
每行分为三种类型:
- comment: 注释行或空行(原样保留)
- entry: 键值对
"""
lines: list[dict] = []
for raw_line in content.splitlines():
stripped = raw_line.strip()
if not stripped or stripped.startswith("#"):
lines.append({"type": "comment", "raw": raw_line})
else:
# 支持 KEY=VALUE 和 KEY="VALUE" 格式
match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)=(.*)', raw_line)
if match:
key = match.group(1)
value = match.group(2).strip()
# 去除引号包裹
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
lines.append({"type": "entry", "key": key, "value": value, "raw": raw_line})
else:
# 无法解析的行当作注释保留
lines.append({"type": "comment", "raw": raw_line})
return lines
def _read_env_file(path: Path) -> str:
"""读取 .env 文件内容。"""
if not path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=".env 文件不存在",
)
return path.read_text(encoding="utf-8")
def _write_env_file(path: Path, content: str) -> None:
"""写入 .env 文件。"""
try:
path.write_text(content, encoding="utf-8")
except OSError as exc:
logger.error("写入 .env 文件失败: %s", exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="写入 .env 文件失败",
)
def _validate_entries(entries: list[EnvEntry]) -> None:
"""验证键值对格式。"""
for idx, entry in enumerate(entries):
if not entry.key:
raise HTTPException(
status_code=422,
detail=f"{idx + 1} 行:键名不能为空",
)
if not re.match(r'^[A-Za-z_][A-Za-z0-9_]*$', entry.key):
raise HTTPException(
status_code=422,
detail=f"{idx + 1} 行:键名 '{entry.key}' 格式无效(仅允许字母、数字、下划线,且不能以数字开头)",
)
# ── GET /api/env-config — 读取 ───────────────────────────────
@router.get("", response_model=EnvConfigResponse)
async def get_env_config(
user: CurrentUser = Depends(get_current_user),
) -> EnvConfigResponse:
"""读取 .env 文件,敏感值以掩码展示。"""
content = _read_env_file(_ENV_PATH)
parsed = _parse_env(content)
entries = []
for line in parsed:
if line["type"] == "entry":
value = _MASK if _is_sensitive(line["key"]) else line["value"]
entries.append(EnvEntry(key=line["key"], value=value))
return EnvConfigResponse(entries=entries)
# ── PUT /api/env-config — 写入 ───────────────────────────────
@router.put("", response_model=EnvConfigResponse)
async def update_env_config(
body: EnvConfigUpdateRequest,
user: CurrentUser = Depends(get_current_user),
) -> EnvConfigResponse:
"""验证并写入 .env 文件。
保留原文件中的注释行和空行。对于已有键,更新值;
对于新键,追加到文件末尾。掩码值(****)的键跳过更新,保留原值。
"""
_validate_entries(body.entries)
# 读取原文件(如果存在)
if _ENV_PATH.exists():
original_content = _ENV_PATH.read_text(encoding="utf-8")
parsed = _parse_env(original_content)
else:
parsed = []
# 构建新值映射(跳过掩码值)
new_values: dict[str, str] = {}
for entry in body.entries:
if entry.value != _MASK:
new_values[entry.key] = entry.value
# 更新已有行
seen_keys: set[str] = set()
output_lines: list[str] = []
for line in parsed:
if line["type"] == "comment":
output_lines.append(line["raw"])
elif line["type"] == "entry":
key = line["key"]
seen_keys.add(key)
if key in new_values:
output_lines.append(f"{key}={new_values[key]}")
else:
# 保留原值(包括掩码跳过的敏感键)
output_lines.append(line["raw"])
# 追加新键
for entry in body.entries:
if entry.key not in seen_keys and entry.value != _MASK:
output_lines.append(f"{entry.key}={entry.value}")
new_content = "\n".join(output_lines)
if output_lines:
new_content += "\n"
_write_env_file(_ENV_PATH, new_content)
# 返回更新后的配置(敏感值掩码)
result_parsed = _parse_env(new_content)
entries = []
for line in result_parsed:
if line["type"] == "entry":
value = _MASK if _is_sensitive(line["key"]) else line["value"]
entries.append(EnvEntry(key=line["key"], value=value))
return EnvConfigResponse(entries=entries)
# ── GET /api/env-config/export — 导出 ────────────────────────
@router.get("/export")
async def export_env_config(
user: CurrentUser = Depends(get_current_user),
) -> PlainTextResponse:
"""导出去除敏感值的配置文件(作为文件下载)。"""
content = _read_env_file(_ENV_PATH)
parsed = _parse_env(content)
output_lines: list[str] = []
for line in parsed:
if line["type"] == "comment":
output_lines.append(line["raw"])
elif line["type"] == "entry":
if _is_sensitive(line["key"]):
output_lines.append(f"{line['key']}={_MASK}")
else:
output_lines.append(line["raw"])
export_content = "\n".join(output_lines)
if output_lines:
export_content += "\n"
return PlainTextResponse(
content=export_content,
media_type="text/plain",
headers={"Content-Disposition": "attachment; filename=env-config.txt"},
)