diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4101cb6 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copy to .env for local deployment. +# Generate SECRET_KEY with: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY= + +# Set ADMIN_PASSWORD to create or rotate the admin account on startup. +ADMIN_USERNAME=admin +ADMIN_PASSWORD= + +# Default: 16 MiB +MAX_UPLOAD_BYTES=16777216 diff --git a/docker-compose.yml b/docker-compose.yml index 6780b89..8ad1989 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,11 @@ services: image: pipeline-lifetime:latest container_name: pipeline-lifetime restart: unless-stopped + environment: + SECRET_KEY: ${SECRET_KEY:-} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-16777216} ports: - "5005:5005" volumes: diff --git a/final_flask_app_0331_strict_fixed_F.py b/final_flask_app_0331_strict_fixed_F.py index 80e0a0e..4e632b4 100644 --- a/final_flask_app_0331_strict_fixed_F.py +++ b/final_flask_app_0331_strict_fixed_F.py @@ -11,8 +11,9 @@ from __future__ import annotations import logging import os -import random +import secrets import sys +import uuid from dataclasses import dataclass from datetime import datetime from io import BytesIO @@ -84,6 +85,7 @@ FEATURES = [ ] ID_COLUMN = "管道编号" +PIPE_AGE_COLUMN = "管龄" TEMPLATE_COLUMNS = [ ID_COLUMN, @@ -101,10 +103,28 @@ logging.basicConfig( ) app = Flask(__name__) -app.config["SECRET_KEY"] = "pipe-survival-0331-strict-secret" + + +def env_int(name: str, default: int) -> int: + try: + return int(os.environ.get(name, str(default))) + except ValueError: + logging.warning("%s 配置无效,使用默认值 %s", name, default) + return default + + +secret_key = os.environ.get("SECRET_KEY") +if not secret_key: + secret_key = secrets.token_hex(32) + logging.warning("未设置 SECRET_KEY,已生成临时密钥;服务重启后登录会话将失效。") + +app.config["SECRET_KEY"] = secret_key app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DATA_DIR / 'pipe_survival_0331.db'}" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["UPLOAD_FOLDER"] = str(UPLOAD_DIR) +app.config["MAX_CONTENT_LENGTH"] = env_int("MAX_UPLOAD_BYTES", 16 * 1024 * 1024) +app.config["SESSION_COOKIE_HTTPONLY"] = True +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" db = SQLAlchemy(app) login_manager = LoginManager(app) @@ -112,6 +132,14 @@ login_manager.login_view = "login" login_manager.login_message = "请先登录后再访问该页面。" +@app.errorhandler(413) +def handle_file_too_large(_exc): + max_mb = app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024) + if request.path == "/predict": + return jsonify({"error": f"文件过大,请上传 {max_mb}MB 以内的文件。"}), 413 + return f"文件过大,请上传 {max_mb}MB 以内的文件。", 413 + + class User(UserMixin, db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) @@ -145,6 +173,36 @@ def load_user(user_id: str): return db.session.get(User, int(user_id)) +def csrf_token() -> str: + token = session.get("_csrf_token") + if not token: + token = secrets.token_urlsafe(32) + session["_csrf_token"] = token + return token + + +def validate_csrf_token() -> bool: + expected = session.get("_csrf_token", "") + received = request.form.get("csrf_token") or request.headers.get("X-CSRFToken", "") + return bool(expected and received and secrets.compare_digest(expected, received)) + + +def new_captcha() -> str: + alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + return "".join(secrets.choice(alphabet) for _ in range(5)) + + +@app.before_request +def protect_csrf(): + if request.method not in {"POST", "PUT", "PATCH", "DELETE"}: + return None + if validate_csrf_token(): + return None + if request.path == "/predict": + return jsonify({"error": "CSRF 校验失败,请刷新页面后重试。"}), 400 + abort(400) + + @dataclass class PipeSummary: pipe_id: str @@ -160,6 +218,13 @@ def resource_path(relative_path: str) -> str: return str((base_path / relative_path).resolve()) +def safe_unlink(path: Path) -> None: + try: + path.unlink(missing_ok=True) + except OSError as exc: + logging.warning("删除文件失败 %s: %s", path, exc) + + def ensure_dirs() -> None: for path in [DATA_DIR, STATIC_DIR, IMAGE_DIR, UPLOAD_DIR]: path.mkdir(parents=True, exist_ok=True) @@ -169,11 +234,25 @@ def init_app() -> None: ensure_dirs() with app.app_context(): db.create_all() - if not User.query.filter_by(username="admin").first(): - admin = User(username="admin", is_admin=True) - admin.set_password("admin123") - db.session.add(admin) + admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin" + admin_password = os.environ.get("ADMIN_PASSWORD") + if admin_password: + admin = User.query.filter_by(username=admin_username).first() + if admin is None: + admin = User(username=admin_username, is_admin=True) + admin.set_password(admin_password) + db.session.add(admin) + else: + admin.is_admin = True + if not admin.check_password(admin_password): + admin.set_password(admin_password) db.session.commit() + elif not User.query.filter_by(is_admin=True).first(): + logging.warning("未设置 ADMIN_PASSWORD,跳过自动创建管理员账号。") + + default_admin = User.query.filter_by(username="admin", is_admin=True).first() + if default_admin and default_admin.check_password("admin123"): + logging.warning("检测到默认管理员密码 admin123,请立即通过 ADMIN_PASSWORD 更新。") try: app.config["RSF_MODEL"] = load_model() @@ -194,7 +273,7 @@ def load_model(): def inject_helpers(): def now_year() -> int: return datetime.now().year - return {"feature_list": FEATURES, "now_year": now_year} + return {"feature_list": FEATURES, "now_year": now_year, "csrf_token": csrf_token} BASE_TEMPLATE_HEAD = r""" @@ -313,6 +392,7 @@ LOGIN_TEMPLATE = r""" {% if mode == 'login' %}