Add system settings, one-click deploy, and simplify embed UI.
Remove Gate scout auto-seeding and the manual hub login button while keeping Gate login and instance SSO. Add password change and database backup/restore, plus deploy scripts with env auto-generation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,18 +1,20 @@
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from flask import Flask, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask import Flask, flash, jsonify, redirect, render_template, request, send_file, url_for
|
||||
from flask_login import LoginManager, current_user, login_required, login_user, logout_user
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from forms import GroupForm, LoginForm, ServiceForm
|
||||
from forms import ChangePasswordForm, GroupForm, LoginForm, RestoreBackupForm, ServiceForm
|
||||
from models import Service, ServiceGroup, User, db
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
@@ -301,7 +303,6 @@ def create_app() -> Flask:
|
||||
_migrate_schema()
|
||||
_ensure_default_user()
|
||||
_ensure_admin_from_env()
|
||||
_ensure_gate_scout_services()
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
@@ -624,6 +625,68 @@ def create_app() -> Flask:
|
||||
flash("服务已删除", "success")
|
||||
return redirect(url_for("admin_services"))
|
||||
|
||||
# ---------- 系统设置 ----------
|
||||
@app.route("/admin/settings", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def admin_settings():
|
||||
pwd_form = ChangePasswordForm()
|
||||
restore_form = RestoreBackupForm()
|
||||
db_path = _resolve_db_file()
|
||||
db_size = db_path.stat().st_size if db_path.is_file() else 0
|
||||
|
||||
if pwd_form.validate_on_submit():
|
||||
if not current_user.check_password(pwd_form.current_password.data):
|
||||
flash("原密码不正确", "error")
|
||||
else:
|
||||
current_user.set_password(pwd_form.new_password.data)
|
||||
db.session.commit()
|
||||
flash("密码已更新", "success")
|
||||
return redirect(url_for("admin_settings"))
|
||||
|
||||
if restore_form.validate_on_submit():
|
||||
upload = restore_form.backup_file.data
|
||||
if not upload or not upload.filename:
|
||||
flash("请选择要恢复的 .db 文件", "error")
|
||||
else:
|
||||
try:
|
||||
_restore_db_from_upload(upload)
|
||||
flash(
|
||||
"数据库已恢复。请重启 LocalNav 服务使变更完全生效。",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("admin_settings"))
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "error")
|
||||
except OSError as exc:
|
||||
flash(f"恢复失败:{exc}", "error")
|
||||
|
||||
return render_template(
|
||||
"admin_settings.html",
|
||||
pwd_form=pwd_form,
|
||||
restore_form=restore_form,
|
||||
db_path=str(db_path),
|
||||
db_size=db_size,
|
||||
)
|
||||
|
||||
@app.route("/admin/settings/backup")
|
||||
@login_required
|
||||
def admin_settings_backup():
|
||||
try:
|
||||
db_path = _resolve_db_file()
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(url_for("admin_settings"))
|
||||
if not db_path.is_file():
|
||||
flash("数据库文件不存在", "error")
|
||||
return redirect(url_for("admin_settings"))
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return send_file(
|
||||
db_path,
|
||||
as_attachment=True,
|
||||
download_name=f"nav_local_{ts}.db",
|
||||
mimetype="application/octet-stream",
|
||||
)
|
||||
|
||||
if os.environ.get("NAV_TRUST_PROXY") == "1":
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
|
||||
@@ -712,95 +775,42 @@ def _ensure_default_user() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_gate_scout_services() -> None:
|
||||
"""gate_scout_order:扫描端 + 执行器(NAV_SEED_GATE_SCOUT=1;云服务器用域名 + https)。"""
|
||||
flag = (os.environ.get("NAV_SEED_GATE_SCOUT") or "").strip().lower()
|
||||
if flag not in ("1", "true", "yes", "on"):
|
||||
return
|
||||
def _resolve_db_file() -> Path:
|
||||
"""从 NAV_DATABASE_URL 解析 SQLite 文件路径。"""
|
||||
uri = os.environ.get("NAV_DATABASE_URL", "sqlite:///nav_local.db")
|
||||
if not uri.startswith("sqlite:///"):
|
||||
raise ValueError("当前仅支持 SQLite 数据库的备份与恢复")
|
||||
path_part = uri[len("sqlite:///") :]
|
||||
if not path_part:
|
||||
raise ValueError("数据库路径配置无效")
|
||||
if path_part.startswith("/"):
|
||||
return Path(path_part)
|
||||
return _ROOT / path_part
|
||||
|
||||
scheme = (os.environ.get("NAV_GATE_SCOUT_SCHEME") or "http").strip().lower()
|
||||
if scheme not in ("http", "https"):
|
||||
scheme = "http"
|
||||
default_host = (os.environ.get("NAV_GATE_SCOUT_HOST") or "127.0.0.1").strip() or "127.0.0.1"
|
||||
scout_host = (os.environ.get("NAV_GATE_SCOUT_SCOUT_HOST") or default_host).strip() or default_host
|
||||
exec_host = (os.environ.get("NAV_GATE_EXECUTOR_HOST") or default_host).strip() or default_host
|
||||
default_port = "443" if scheme == "https" else "8088"
|
||||
default_exec_port = "443" if scheme == "https" else "8090"
|
||||
try:
|
||||
scout_port = int(os.environ.get("NAV_GATE_SCOUT_PORT") or default_port)
|
||||
exec_port = int(os.environ.get("NAV_GATE_EXECUTOR_PORT") or default_exec_port)
|
||||
except ValueError:
|
||||
scout_port = 443 if scheme == "https" else 8088
|
||||
exec_port = 443 if scheme == "https" else 8090
|
||||
|
||||
scout_path = (os.environ.get("NAV_GATE_SCOUT_PATH") or "/dashboard").strip() or "/dashboard"
|
||||
exec_path = (os.environ.get("NAV_GATE_EXECUTOR_PATH") or "/dashboard").strip() or "/dashboard"
|
||||
if not scout_path.startswith("/"):
|
||||
scout_path = "/" + scout_path
|
||||
if not exec_path.startswith("/"):
|
||||
exec_path = "/" + exec_path
|
||||
def _restore_db_from_upload(upload) -> None:
|
||||
filename = (upload.filename or "").lower()
|
||||
if not filename.endswith(".db"):
|
||||
raise ValueError("仅支持 .db 格式的 SQLite 备份文件")
|
||||
upload.seek(0, os.SEEK_END)
|
||||
size = upload.tell()
|
||||
upload.seek(0)
|
||||
if size > 50 * 1024 * 1024:
|
||||
raise ValueError("备份文件过大(上限 50MB)")
|
||||
if size < 512:
|
||||
raise ValueError("备份文件过小,可能已损坏")
|
||||
|
||||
update_existing = (os.environ.get("NAV_GATE_SCOUT_UPDATE") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
db_path = _resolve_db_file()
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if db_path.is_file():
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
bak = db_path.with_name(f"{db_path.name}.bak.{ts}")
|
||||
shutil.copy2(db_path, bak)
|
||||
|
||||
group_name = (os.environ.get("NAV_GATE_SCOUT_GROUP") or "Gate 扫单").strip() or "Gate 扫单"
|
||||
g = ServiceGroup.query.filter_by(name=group_name).first()
|
||||
if not g:
|
||||
g = ServiceGroup(name=group_name, sort_order=50)
|
||||
db.session.add(g)
|
||||
db.session.flush()
|
||||
|
||||
defs = (
|
||||
("Gate 扫描端", scout_host, scout_port, scout_path, 0, "gate_scout"),
|
||||
("Gate 下单执行器", exec_host, exec_port, exec_path, 10, "gate_exec"),
|
||||
)
|
||||
added = 0
|
||||
updated = 0
|
||||
for name, h, port, path, order, embed_k in defs:
|
||||
existing = Service.query.filter_by(group_id=g.id, name=name).first()
|
||||
if existing:
|
||||
if update_existing and (
|
||||
existing.scheme != scheme
|
||||
or existing.host != h
|
||||
or existing.port != port
|
||||
or existing.path != path
|
||||
or (existing.embed_kind or "") != embed_k
|
||||
):
|
||||
existing.scheme = scheme
|
||||
existing.host = h
|
||||
existing.port = port
|
||||
existing.path = path
|
||||
existing.embed_kind = embed_k
|
||||
updated += 1
|
||||
elif not (existing.embed_kind or "").strip():
|
||||
existing.embed_kind = embed_k
|
||||
updated += 1
|
||||
continue
|
||||
db.session.add(
|
||||
Service(
|
||||
name=name,
|
||||
scheme=scheme,
|
||||
host=h,
|
||||
port=port,
|
||||
path=path,
|
||||
sort_order=order,
|
||||
group_id=g.id,
|
||||
embed_kind=embed_k,
|
||||
)
|
||||
)
|
||||
added += 1
|
||||
if added or updated:
|
||||
db.session.commit()
|
||||
print(
|
||||
f"[nav] Gate 扫单:新增 {added}、更新 {updated}(分组「{group_name}」)。"
|
||||
f" 扫描 {scheme}://{scout_host}:{scout_port}{scout_path};"
|
||||
f"执行器 {scheme}://{exec_host}:{exec_port}{exec_path}",
|
||||
flush=True,
|
||||
)
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
with open(db_path, "wb") as out:
|
||||
shutil.copyfileobj(upload.stream, out)
|
||||
|
||||
|
||||
def _ensure_admin_from_env() -> None:
|
||||
|
||||
Reference in New Issue
Block a user