-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathweb.py
More file actions
259 lines (218 loc) · 10.5 KB
/
Copy pathweb.py
File metadata and controls
259 lines (218 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
"""
Holland2Stay 监控 Web 面板 — 应用引导层
==========================================
职责
----
本文件**只**负责 Flask app 的引导与组装:
- 实例化 Flask,配置 session cookie / 安全头
- 注册 CSRF + Jinja 过滤器
- 注册全局 context_processor(i18n + 鉴权状态)
- 依次调用各 ``app.routes.*`` 模块的 ``register(app)``
所有具体路由实现已拆分到 ``app/routes/`` 下的独立模块。
所有共享工具已拆分到 ``app/`` 的对应模块(auth / csrf / i18n / ...)。
运行方式
--------
python web.py # 本地开发,默认 http://localhost:8088
python web.py --port 8080 # 自定义端口
Docker 容器中由 Gunicorn 启动(supervisord.conf):
gunicorn --workers=1 --threads=8 --timeout=0 --bind=0.0.0.0:8088 web:app
(直接运行 python web.py 仅用于本地调试)
"""
from __future__ import annotations
import argparse
import logging.handlers
import os
import sys
import time
from pathlib import Path
from flask import Flask, g, request
if not getattr(sys, "frozen", False):
sys.path.insert(0, str(Path(__file__).resolve().parent))
from config import ASSETS_DIR, DATA_DIR # noqa: E402
from translations import tr as _tr # noqa: E402
# 配置 Web 进程日志:独立文件 data/web.log,避免与 monitor 进程写冲突。
# 注意:此文件记录 Flask 应用自身的日志(请求处理、配置变更等),
# 与 supervisord 重定向的 Gunicorn stdout(/app/logs/web.log)是不同文件。
# Web 面板「日志查看」页面读取的是本文件。
_DATA_DIR = Path(os.environ.get("DATA_DIR", str(DATA_DIR)))
_DATA_DIR.mkdir(parents=True, exist_ok=True)
_fh = logging.handlers.RotatingFileHandler(
str(_DATA_DIR / "web.log"),
maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8",
)
_fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
_fh.setLevel(logging.INFO)
logging.getLogger().setLevel(logging.INFO)
logging.getLogger().addHandler(_fh)
# 屏蔽 Werkzeug HTTP 访问日志,只保留 WARNING+(如 5xx 错误)
logging.getLogger("werkzeug").setLevel(logging.WARNING)
# app/ 子包
from app import csrf as _csrf # noqa: E402
from app import jinja_filters # noqa: E402
from app.auth import ( # noqa: E402
auth_enabled,
current_user_id,
ensure_secret_key,
guest_mode_enabled,
is_admin,
is_user,
)
from app.i18n import get_lang # noqa: E402
from app.routes import ( # noqa: E402
app_accounts,
calendar_routes,
control,
dashboard,
email_verify as email_verify_routes,
inbound,
legal,
map_routes,
notifications,
sessions,
settings as settings_routes,
stats,
system,
users,
)
from app.routes import api_v1 # noqa: E402
# ------------------------------------------------------------------ #
# Flask app
# ------------------------------------------------------------------ #
app = Flask(
__name__,
template_folder=str(ASSETS_DIR / "templates"),
static_folder=str(ASSETS_DIR / "static"),
)
# SameSite=Lax:阻止跨站 POST 请求携带 session cookie(主要 CSRF 防护层)。
# HttpOnly=True:禁止 JS 读取 session cookie(Flask 默认已是 True,此处显式声明)。
# Secure=True:仅 HTTPS 下发送 cookie;本地开发通过 SESSION_COOKIE_SECURE=false 关闭。
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("SESSION_COOKIE_SECURE", "false").lower() == "true"
app.config["PERMANENT_SESSION_LIFETIME"] = int(os.environ.get("SESSION_LIFETIME_HOURS", "24")) * 3600
# 稳定的 secret key:优先读 .env,不存在则自动生成并写入
app.secret_key = ensure_secret_key()
@app.teardown_appcontext
def _close_request_storage(exc=None):
"""关闭请求作用域的 SQLite 连接(若存在)。
仅关闭由 app.db.storage() 标记为 _teardown_managed=True 的实例;
路由内部自行创建的 Storage(如 SSE 生成器内的)不受影响。
"""
st = g.pop('_storage', None)
if st is not None:
# 绕过 _teardown_managed 检查,强制关闭
st._teardown_managed = False
st.close()
@app.after_request
def _add_security_headers(resp):
resp.headers.setdefault("X-Frame-Options", "DENY")
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
resp.headers.setdefault("Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload")
# CSP: allow self, inline styles (design.css vars), Google Fonts, CDN (icons + charts), maps.
resp.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; "
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; "
"img-src 'self' data: https://*.tile.openstreetmap.org https://maps.googleapis.com https://maps.gstatic.com; "
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://maps.googleapis.com; "
"connect-src 'self' https://maps.googleapis.com https://maps.gstatic.com; "
"worker-src 'self' blob:; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'",
)
# ── 静态资源缓存 ──────────────────────────────────────────────
# /static/ 下的文件都通过 `?v=NN` 查询字符串做 cache bust(design.css?v=15、
# app.js?v=5 等)。URL 不变就能命中缓存。
#
# 缓存周期 30 天:在"重复访问命中率高"和"忘记 bump v=N 时痛苦时长"
# 之间折中——理论可以拉到 1 年(immutable 标准做法),但如果某次部署
# 改了 design.css 忘记 bump,用户/Cloudflare 边缘缓存会卡 1 年;30
# 天意味着最差 30 天内就会自动 revalidate。
#
# 不带版本号的 favicon / logo 给更保守的 1 天,因为它们没 cache-bust 机制。
if resp.status_code == 200 and request.path.startswith("/static/"):
if request.query_string:
# cache-busted URL → 30 天
resp.headers["Cache-Control"] = "public, max-age=2592000"
else:
resp.headers["Cache-Control"] = "public, max-age=86400"
return resp
# ------------------------------------------------------------------ #
# Jinja 全局:过滤器 + CSRF + i18n + 鉴权状态
# ------------------------------------------------------------------ #
jinja_filters.register(app)
_csrf.register(app)
@app.context_processor
def _inject_auth():
return {
"auth_enabled": auth_enabled(),
"is_admin": is_admin(),
"is_user": is_user(),
"current_user_id": current_user_id(),
"guest_mode": guest_mode_enabled(),
}
@app.context_processor
def _inject_translations():
lang = get_lang()
def _(key: str) -> str:
return _tr(key, lang)
return {"_": _, "lang": lang}
@app.context_processor
def _inject_upstream_maintenance():
"""
向所有模板注入 H2S 平台维护态。
base.html 用 `upstream_maintenance.active` 决定是否渲染顶部 banner。
异常时静默——dashboard 永远不应该因为状态查询失败而崩。
5s TTL 缓存:避免每次页面渲染都读 SQLite meta 表。
"""
now = time.monotonic()
cache = getattr(_inject_upstream_maintenance, "_cache", None)
if cache is not None and (now - cache[0]) < 5:
return {"upstream_maintenance": cache[1]}
try:
from app.services.monitor_service import get_upstream_maintenance
info = get_upstream_maintenance()
except Exception:
info = {"active": "", "since": "", "last_seen": ""}
_inject_upstream_maintenance._cache = (now, info)
return {"upstream_maintenance": info}
# ------------------------------------------------------------------ #
# 路由:每个 app.routes.* 模块挂自己的 endpoint,扁平命名(A 方案)
# ------------------------------------------------------------------ #
sessions.register(app) # /login /logout /guest /set-lang
dashboard.register(app) # / /listings
users.register(app) # /users*
email_verify_routes.register(app) # /verify-email/<token> /users/<id>/resend-verify
settings_routes.register(app) # /settings
map_routes.register(app) # /map /api/map* /api/neighborhoods
calendar_routes.register(app) # /calendar /api/calendar
stats.register(app) # /stats /api/charts
system.register(app) # /system /logs /api/logs* /api/status /api/platform /health /api/reset-db
control.register(app) # /api/reload /api/monitor/{start,stop} /api/shutdown
notifications.register(app) # /api/notifications* /api/events
inbound.register(app) # /api/inbound/email (Resend webhook,Svix 签名校验,无需登录)
app_accounts.register(app) # /settings/app-accounts (admin: Bearer token 管理)
legal.register(app) # /privacy /terms (公开页面,无需登录)
api_v1.register(app) # /api/v1/auth/* /api/v1/stats/public/* (Bearer token)
# ------------------------------------------------------------------ #
# 入口
# ------------------------------------------------------------------ #
def main() -> None:
# update_checker 触发一次网络请求,仅 CLI 直接运行时需要;
# gunicorn / launcher 启动 web:app 时不会经过 main(),避免无谓的启动开销。
from update_checker import check_for_updates
parser = argparse.ArgumentParser(description="Holland2Stay Web 面板")
parser.add_argument("--port", type=int, default=8088)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--debug", action="store_true", default=os.environ.get("FLASK_DEBUG", "").lower() in {"1", "true", "yes"})
args = parser.parse_args()
check_for_updates()
print(f"Web 面板运行中 → http://{args.host}:{args.port}" + (" (debug)" if args.debug else ""))
# threaded=True:允许多个 SSE 连接并发(每个连接占用一个线程)
app.run(host=args.host, port=args.port, debug=args.debug, threaded=True)
if __name__ == "__main__":
main()