241 lines
7.9 KiB
Python
241 lines
7.9 KiB
Python
# -*- 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"},
|
||
)
|