diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 2428a50..63226f3 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -38,6 +38,28 @@ jobs: --exclude='.gitea' \ "${CLONE_DIR}/" "${RELEASE_DIR}/" + echo "=== 2.5 Ensure .env secrets ===" + # .env 不随 git 走,线上凭据由服务器本地 shared/.env 提供。 + # 若缺失则从 CI secrets 自动补齐;已存在的变量保留,避免覆盖线上配置/导致 session 失效。 + mkdir -p "${DEPLOY_BASE}/shared" + SHARED_ENV="${DEPLOY_BASE}/shared/.env" + touch "${SHARED_ENV}" + ensure_env_var() { + key="$1"; val="$2" + if [ -z "$val" ]; then + echo " ! ${key} 未提供(CI secret 为空),跳过写入" + return + fi + if ! grep -q "^${key}=" "${SHARED_ENV}"; then + echo "${key}=${val}" >> "${SHARED_ENV}" + echo " + 已写入 ${key}" + else + echo " = ${key} 已存在,保留原值" + fi + } + ensure_env_var "DB_PASSWORD" "${{ secrets.OPC_DB_PASSWORD }}" + ensure_env_var "SECRET_KEY" "${{ secrets.OPC_SECRET_KEY }}" + echo "=== 3. Link shared resources ===" mkdir -p "${RELEASE_DIR}/data" # .env from shared dir (not in git) diff --git a/backend/db.py b/backend/db.py index 98ba4ad..d73fcdb 100644 --- a/backend/db.py +++ b/backend/db.py @@ -39,12 +39,22 @@ UPLOAD_DIR.mkdir(parents=True, exist_ok=True) # ---------- 数据库连接 ---------- +def _require_env(name): + """强制读取必需环境变量,缺失即抛出清晰错误(禁止弱默认降级)""" + val = os.environ.get(name) + if not val: + raise RuntimeError( + f"缺少必需的环境变量 {name},请在 .env 中显式配置(关键凭据禁止硬编码兜底)" + ) + return val + + def db(): return mysql.connector.connect( host=os.environ.get("DB_HOST", "127.0.0.1"), port=int(os.environ.get("DB_PORT", "3306")), user=os.environ.get("DB_USER", "opc"), - password=os.environ.get("DB_PASSWORD", "opc123456"), + password=_require_env("DB_PASSWORD"), database=os.environ.get("DB_NAME", "opc"), charset="utf8mb4", collation="utf8mb4_unicode_ci", @@ -55,10 +65,44 @@ def now(): return datetime.utcnow().isoformat() +def _convert_placeholders(sql): + """将 ? 占位符转为 MySQL 的 %s,仅替换字符串字面量之外的 ?。 + + 避免 `sql.replace('?', '%s')` 的全局替换缺陷:当 SQL 字面量或 LIKE 模式 + 中恰好含 `?` 时会误替换、导致参数错位。这里用状态机跳过单/双引号内的内容。 + """ + out = [] + in_single = in_double = False + i, n = 0, len(sql) + while i < n: + ch = sql[i] + if ch == "\\" and i + 1 < n: # 跳过转义序列 + out.append(ch) + out.append(sql[i + 1]) + i += 2 + continue + if ch == "'" and not in_double: + in_single = not in_single + elif ch == '"' and not in_single: + in_double = not in_double + out.append("%s" if (ch == "?" and not in_single and not in_double) else ch) + i += 1 + return "".join(out) + + def _exec(conn, sql, args=()): - """执行 SQL,自动将 ? 转为 MySQL 的 %s""" + """执行 SQL,将 ? 占位符安全地转为 MySQL 的 %s。 + + 占位符数量与参数数量必须一致,否则立即报错,防止静默错位。 + """ + converted = _convert_placeholders(sql) + n_ph = converted.count("%s") + if n_ph != len(args): + raise ValueError( + f"SQL 占位符数量({n_ph})与参数数量({len(args)})不匹配,请检查 SQL 与传参: {sql}" + ) cur = conn.cursor(dictionary=True) - cur.execute(sql.replace("?", "%s"), args) + cur.execute(converted, args) return cur diff --git a/backend/flask_app.py b/backend/flask_app.py index 3b3b7d0..1ba601d 100644 --- a/backend/flask_app.py +++ b/backend/flask_app.py @@ -19,7 +19,14 @@ app = Flask( template_folder=str(ROOT / "templates"), static_folder=str(ROOT / "static"), ) -app.secret_key = os.environ.get("SECRET_KEY", "opc-dev-secret-2026") +# secret_key 必须从环境变量注入,禁止硬编码兜底——否则 session 可被伪造。 +_secret_key = os.environ.get("SECRET_KEY") +if not _secret_key: + raise RuntimeError( + "缺少必需的环境变量 SECRET_KEY,请在 .env 中配置(建议: " + "python3 -c 'import secrets;print(secrets.token_hex(32))')" + ) +app.secret_key = _secret_key app.config["TEMPLATES_AUTO_RELOAD"] = True app.register_blueprint(bp)