Dev qing #9

Merged
qing merged 50 commits from dev_qing into main 2026-07-27 21:47:17 +08:00
51 changed files with 3986 additions and 3189 deletions

No files matched your search

+23 -3
View File
@@ -1,7 +1,7 @@
# micro_servers
一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python 与 Go 混合实现。
`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。
`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。
## 架构总览
@@ -18,12 +18,14 @@
│ waf │ │ gateway │ │ static │ │ dnfs │ │ db │ │ ai_review │
│ WAF 网关 │ │ 应用层网关 │ │前端托管│ │ 树状菜单 │ │ 通用数据库 │ │ AI内容审核 │
│ :443 (入口) │ │ :8443 (内部) │ │ :9200 │ │ :8006 │ │ :8005 │ │ :8007 │
│ coraza+反代 │ │ 鉴权 + 反代 │ │静态/媒体│ │ 节点CRUD │ │ SQL-over-HTTP│ │ DeepSeek+RAG │
│ coraza+反代 │ │ 鉴权 + 反代 │ │静态/媒体│ │ 节点CRUD │ │ SQL+日志收集 │ │ DeepSeek+RAG │
└──────┬───────┘ └──────┬───────┘ └────────┘ └──────┬───────┘ └──────────────┘ └──────────────┘
│ HTTPS终止 │ 反向代理 │ 调用
└──────────────────►│ ├──────────► db:8005
├────────────────► static:9200
└────────────────► dnfs:8006 ──► ai_review:8007
日志链路: waf / gateway / static / dnfs / ai_review / captcha ──(log_client)──► db:8005/log/ingest
```
### 请求链路
@@ -34,6 +36,7 @@
4. 其余请求携带 Token,经 `gateway` 校验后反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。
5. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。
6. `dnfs` 处理节点增删改查时,通过 HTTP 调用 `db` 服务完成数据持久化,新增节点前调用 `ai_review` 进行内容审核。
7. 各服务通过 `log_client.py` SDK 异步批量推送运行日志到 `db:8005/log/ingest`,实现集中式日志收集(fail-safe,db 不可用时静默丢弃)。
## 各服务说明
@@ -44,7 +47,7 @@
| [gateway](gateway/) | C++20 | 8443 | 应用层网关(内部 HTTP),用户注册/登录鉴权、Token 签发与校验、反向代理到内部服务 |
| [static](static/) | Python(Flask) | 9200 | 前端托管服务,社区首页、静态页面与文件浏览、视频/音频流媒体播放、路径遍历防护 |
| [dnfs](dnfs/) | Python(Flask) | 8006 | 树状网络文件系统(Dendrachy Network File-System),基于父子 pid 的节点树增删改查,集成 AI 内容审核 |
| [db](db/) | Python(Flask) | 8005 | 通用数据库服务,基于 SQLAlchemy Core 的 SQL-over-HTTP,支持 select/insert/update/delete,参数化查询防注入 |
| [db](db/) | Python(Flask) | 8005 | 通用数据库服务,基于 SQLAlchemy Core 的 SQL-over-HTTP,支持 select/insert/update/delete,参数化查询防注入;内置集中式日志收集(/log/* 路由) |
| [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
### core(核心模块 · 进程托管服务)
@@ -98,6 +101,15 @@
- 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。
- 仅监听 127.0.0.1,单线程,自动回滚,不暴露 DDL 操作。
- 默认使用 SQLite(`database.db`),可通过 `DATABASE_URL` 环境变量切换至 PostgreSQL / MySQL。
- **内置日志子模块**([log_module.py](db/log_module.py)):
- `log_internal()` — db 进程内部直接写 Logs 表,无 HTTP 往返,防递归设计。
- `/log/ingest` — 接收外部服务批量推送日志(单次上限 100 条,校验 service / level / message)。
- `/log/query` — 结构化查询(service / level / trace_id / 时间区间 / 关键字模糊搜索 / 排序分页)。
- `/log/stats` — 按 service / level 分组统计。
- `/log/purge` — 手动清理指定时间之前的日志。
- `/log/health` — 健康检查。
- 后台守护线程每小时自动清理:保留 30 天 / 上限 100 万条。
- **统一日志客户端 SDK**([log_client.py](db/log_client.py)):各服务复制即用,纯标准库实现(urllib),异步非阻塞(内存队列 + 后台线程,50 条/2 秒批量 flush),fail-safe 设计(db 不可用时静默丢弃,不影响业务)。
### ai_review(AI 内容审核服务)
@@ -116,6 +128,13 @@
| [libcrypt](libcrypt/) | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` |
| [libsqlite3](libsqlite3/) | SQLite3 C++ 封装(Header-only) |
### Python 共享组件
| 文件 | 说明 |
|------|------|
| [log_client.py](db/log_client.py) | 统一日志客户端 SDK,各服务复制即用(纯标准库,异步批量推送) |
| [security_middleware.py](db/security_middleware.py) | Flask 安全响应头中间件(CSP / X-Frame-Options / nosniff 等),各服务各持一份副本 |
## 端口一览
| 端口 | 服务 | 绑定地址 | 说明 |
@@ -207,6 +226,7 @@ gateway 改为内部 HTTP 模式([example.ini](gateway/example.ini) 中 `tls =
- **多语言异构**:C++(core / gateway)、Python(static / dnfs / db / ai_review)、Go(waf)混合,通过 HTTP 通信。
- **数据访问层解耦**:`db` 服务将数据库操作收敛为统一的 HTTP 接口,业务服务无需直连数据库。
- **AI 能力集成**:`ai_review` 以 RAG 增强的 LLM 审核为业务层提供内容安全能力,支持人工反馈闭环。
- **集中式可观测性**:`db` 内置日志收集服务(/log/* 路由),各服务通过 `log_client.py` SDK 异步推送日志,实现全链路汇聚、查询与统计,无需引入额外中间件。
- **安全分层**:`waf` 在边缘做规则匹配(OWASP CRS / 自定义 SecLang 规则),`gateway` 在应用层做鉴权,`db` 仅监听本机,多层防护。
与云原生微服务的差异:未引入服务注册发现、容器编排、消息总线,服务地址在 `waf/config.yaml` 与 `gateway/example.ini` 中静态配置。对于个人/中小规模的 DIY 场景,这种取舍在简洁性与可控性上是合理的。
+1
View File
@@ -34,4 +34,5 @@ inst_module "waf" # waf 在 gateway 之前编译,部署时由 core 先拉起 w
inst_module "static"
inst_module "dnfs"
inst_module "db"
inst_module "smtp"
inst_module "core" # core last
Regular → Executable
View File
File mode changed.
+5 -1
View File
@@ -1,7 +1,11 @@
bash /opt/micro_servers/ai_review/_run.sh
bash /opt/micro_servers/db/_run.sh
bash /opt/micro_servers/ai_review/_run.sh
bash /opt/micro_servers/dnfs/_run.sh
bash /opt/micro_servers/smtp/_run.sh
bash /opt/micro_servers/static/_run.sh
bash /opt/micro_servers/waf/_run.sh
bash /opt/micro_servers/gateway/_run.sh
bash /opt/micro_servers/captcha/_run.sh
#
#ssh-copy-id user@your-server
#ssh -D 1080 -N user@your-server
+14 -13
View File
@@ -279,20 +279,21 @@ try {
return;
}
else if (typ == "kill") {
int trg = jv.at("kill").as_int64();
auto flag = pmc_mtd::kill(trg);
std::string msg = flag ? "OK" : "Nope";
pipe.writePipe(msg);
return;
}
/* 20260727(qing): 在身份验证之前禁止操作 */
//else if (typ == "kill") {
// int trg = jv.at("kill").as_int64();
// auto flag = pmc_mtd::kill(trg);
// std::string msg = flag ? "OK" : "Nope";
// pipe.writePipe(msg);
// return;
//}
else if (typ == "exec"){
std::string cmd = jv.at("exec").as_string().c_str();
std::string msg = (pmc_mtd::exec(cmd)) ? "OK" : "ERR";
pipe.writePipe(msg);
return;
}
//else if (typ == "exec"){
// std::string cmd = jv.at("exec").as_string().c_str();
// std::string msg = (pmc_mtd::exec(cmd)) ? "OK" : "ERR";
// pipe.writePipe(msg);
// return;
//}
}
/* 捕获异常后,仅仅只是打印出来? */
+5 -5
View File
@@ -1,5 +1,5 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
source ./venv/bin/activate
#exec ./main.dist/main.bin
exec python3 main.py
#!/bin/bash
cd "$(dirname "$0")" || exit
source ./venv/bin/activate
#exec ./main.dist/main.bin
exec python3 dbv2.py
+6 -6
View File
@@ -1,6 +1,6 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
#nuitka --standalone main.py
#!/bin/bash
cd "$(dirname "$0")" || exit
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
#nuitka --standalone main.py
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""查看 database.db 各表数据 (非交互式)"""
import argparse
import sqlite3
import os
import sys
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "database.db")
def get_tables(cur):
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
return [r[0] for r in cur.fetchall()]
def print_rows(cols, rows):
if not rows:
print("(空)")
return
widths = [len(c) for c in cols]
str_rows = []
for row in rows:
str_row = [str(v) if v is not None else "NULL" for v in row]
str_rows.append(str_row)
for i, v in enumerate(str_row):
widths[i] = max(widths[i], min(len(v), 50))
print(" | ".join(c.ljust(widths[i]) for i, c in enumerate(cols)))
print("-+-".join("-" * w for w in widths))
for str_row in str_rows:
print(" | ".join(v[:50].ljust(widths[i]) for i, v in enumerate(str_row)))
print(f"\n({len(rows)} 行)")
def main():
parser = argparse.ArgumentParser(description="查看 database.db")
parser.add_argument("table", nargs="?", help="要查看的表名 (不指定则列出所有表)")
parser.add_argument("-n", "--limit", type=int, default=50, help="最多显示行数 (默认 50)")
parser.add_argument("-w", "--where", help="WHERE 条件, 如: \"level='ERROR'\"")
parser.add_argument("-s", "--sql", help="自由 SQL 查询 (忽略 table/where/limit)")
args = parser.parse_args()
if not os.path.exists(DB_PATH):
print(f"数据库不存在: {DB_PATH}", file=sys.stderr)
sys.exit(1)
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
tables = get_tables(cur)
# 自由 SQL
if args.sql:
cur.execute(args.sql)
cols = [d[0] for d in cur.description]
print_rows(cols, cur.fetchall())
conn.close()
return
# 列出所有表
if not args.table:
print(f"数据库: {DB_PATH}\n")
for t in tables:
cur.execute(f"SELECT COUNT(*) FROM [{t}]")
print(f" {t} ({cur.fetchone()[0]} 行)")
conn.close()
return
# 查看指定表
if args.table not in tables:
print(f"表 '{args.table}' 不存在, 可用: {', '.join(tables)}", file=sys.stderr)
sys.exit(1)
sql = f"SELECT * FROM [{args.table}]"
if args.where:
sql += f" WHERE {args.where}"
sql += f" LIMIT {args.limit}"
cur.execute(sql)
cols = [d[0] for d in cur.description]
print_rows(cols, cur.fetchall())
conn.close()
if __name__ == "__main__":
main()
+363
View File
@@ -0,0 +1,363 @@
"""
dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。
替代 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
协议:每条消息为完整 JSON,新增 ``_route`` 字段对应原 HTTP 路由路径。
请求示例::
{
"_route": "execute",
"action": "select",
"table": "Users",
"where": {"age": {"gt": 20}},
"order_by": [{"column": "id", "desc": true}],
"limit": 10
}
支持的路由::
execute → 通用 CRUD(select / insert / update / delete)
"""
import json
import os
import time
import traceback
from datetime import datetime, timezone, timedelta
from sqlalchemy import (
create_engine, MetaData, Table,
select, insert, update, delete, func, and_,
)
from sqlalchemy.exc import SQLAlchemyError
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import MsgServer # noqa: E402
# ── 数据库配置 ──────────────────────────────────────────────────────────
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///database.db")
# ── 日志模块常量 ────────────────────────────────────────────────────────
MAX_MESSAGE_LEN = 8192 # message 上限 8KB
MAX_EXTRA_LEN = 4096 # extra 上限 4KB
DEFAULT_RETENTION_DAYS = 30
DEFAULT_MAX_RECORDS = 1_000_000
DEFAULT_CLEANUP_INTERVAL = 3600 # 秒
# ═══════════════════════════════════════════════════════════════════════
# DBServer — 主服务类
# ═══════════════════════════════════════════════════════════════════════
class DBServer:
"""基于 pymsg 的数据库服务,通过 TCP 长连接收发 JSON。"""
def __init__(self, addr="127.0.0.1", port=8005):
self.addr = addr
self.port = port
self.engine = create_engine(DATABASE_URL)
self.metadata = MetaData()
self._server = MsgServer(addr, port)
# 日志表缓存(避免重复反射)
self._logs_table_cache = {}
# 路由表
self._routes = {
"execute": self._handle_execute,
}
# ── 启动 / 停止 ──────────────────────────────────────────────────
def start(self) -> None:
"""启动服务(阻塞当前线程)。"""
print(f"[dbv2] 监听 tcp://{self.addr}:{self.port}")
self._ensure_logs_table() # 确保 Logs 表已建好(外部 MsgClient 写日志前)
self._start_cleanup_thread()
self._server.start(self._dispatch)
def stop(self) -> None:
"""从另一线程停止服务。"""
self._server.stop()
# ── 消息调度 -------------------------------------------------------
def _dispatch(self, sock, addr, text):
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
try:
req = json.loads(text)
except json.JSONDecodeError as e:
self._reply(sock, False, error=f"JSON 解析失败: {e}")
return
route = req.pop("_route", "")
handler = self._routes.get(route)
if handler is None:
self._reply(sock, False, error=f"未知路由: {route}")
return
try:
result = handler(req, sock=sock)
self._reply(sock, True, data=result)
except Exception as e:
traceback.print_exc()
self._reply(sock, False, error=str(e))
def _reply(self, sock, success, data=None, error=None):
resp = {"success": success}
if data is not None:
resp["data"] = data
if error is not None:
resp["error"] = error
self._server.reply(sock, json.dumps(resp, ensure_ascii=False, default=str))
# ── 表反射 ---------------------------------------------------------
def _get_table(self, table_name):
if table_name not in self.metadata.tables:
Table(table_name, self.metadata, autoload_with=self.engine)
return self.metadata.tables[table_name]
def _build_where(self, table, conditions):
if not conditions:
return None
clauses = []
for col, val in conditions.items():
column = table.columns[col]
if isinstance(val, dict):
if "eq" in val:
clauses.append(column == val["eq"])
if "ne" in val:
clauses.append(column != val["ne"])
if "gt" in val:
clauses.append(column > val["gt"])
if "lt" in val:
clauses.append(column < val["lt"])
if "ge" in val:
clauses.append(column >= val["ge"])
if "le" in val:
clauses.append(column <= val["le"])
if "like" in val:
clauses.append(column.like(val["like"]))
if "in" in val:
clauses.append(column.in_(val["in"]))
else:
clauses.append(column == val)
return and_(*clauses)
# ═══════════════════════════════════════════════════════════════════
# 路由处理 — execute
# ═══════════════════════════════════════════════════════════════════
def _handle_execute(self, data, **kw):
action = data.get("action", "select")
table_name = data.get("table")
if not table_name:
raise ValueError("缺少 table 字段")
try:
table = self._get_table(table_name)
except Exception as e:
raise ValueError(f"表加载失败: {e}")
with self.engine.connect() as conn:
if action == "select":
return self._do_select(conn, table, data)
elif action == "insert":
return self._do_insert(conn, table, data)
elif action == "update":
return self._do_update(conn, table, data)
elif action == "delete":
return self._do_delete(conn, table, data)
else:
raise ValueError(f"不支持的操作: {action}")
def _do_select(self, conn, table, data):
columns = data.get("columns")
if columns:
cols = [table.columns[c] for c in columns]
else:
cols = [table]
stmt = select(*cols)
where_cond = self._build_where(table, data.get("where"))
if where_cond is not None:
stmt = stmt.where(where_cond)
order_by = data.get("order_by")
if order_by:
for item in order_by:
col = table.columns[item["column"]]
stmt = stmt.order_by(col.desc() if item.get("desc") else col.asc())
limit = data.get("limit")
if limit is not None:
stmt = stmt.limit(limit)
offset = data.get("offset")
if offset is not None:
stmt = stmt.offset(offset)
res = conn.execute(stmt)
return [dict(row._mapping) for row in res.fetchall()]
def _do_insert(self, conn, table, data):
values = data.get("values", {})
stmt = insert(table).values(**values)
res = conn.execute(stmt)
conn.commit()
return [{"lastrowid": res.lastrowid}] if hasattr(res, "lastrowid") else []
def _do_update(self, conn, table, data):
values = data.get("values", {})
where_cond = self._build_where(table, data.get("where"))
stmt = update(table).values(**values)
if where_cond is not None:
stmt = stmt.where(where_cond)
res = conn.execute(stmt)
conn.commit()
return [{"rowcount": res.rowcount}]
def _do_delete(self, conn, table, data):
where_cond = self._build_where(table, data.get("where"))
stmt = delete(table)
if where_cond is not None:
stmt = stmt.where(where_cond)
res = conn.execute(stmt)
conn.commit()
return [{"rowcount": res.rowcount}]
# ═══════════════════════════════════════════════════════════════════
# 日志表管理 + 后台清理
# ═══════════════════════════════════════════════════════════════════
# ── 日志表管理 ────────────────────────────────────────────────────
def _ensure_logs_table(self):
if "Logs" in self._logs_table_cache:
return self._logs_table_cache["Logs"]
with self.engine.connect() as conn:
conn.exec_driver_sql("""
CREATE TABLE IF NOT EXISTS Logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
service TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
trace_id TEXT,
extra TEXT
)
""")
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)"
)
conn.commit()
table = Table("Logs", self.metadata, autoload_with=self.engine)
self._logs_table_cache["Logs"] = table
return table
# ── 内部日志(直接写库,无 HTTP)──────────────────────────────────
def _log_internal(self, service, level, message,
trace_id=None, extra=None):
try:
table = self._ensure_logs_table()
extra_json = json.dumps(extra, ensure_ascii=False) if extra else None
if len(message) > MAX_MESSAGE_LEN:
message = message[:MAX_MESSAGE_LEN]
if extra_json and len(extra_json) > MAX_EXTRA_LEN:
extra_json = extra_json[:MAX_EXTRA_LEN]
with self.engine.connect() as conn:
conn.execute(insert(table).values(
ts=datetime.now(timezone.utc).isoformat(),
service=service,
level=level,
message=message,
trace_id=trace_id,
extra=extra_json,
))
conn.commit()
except Exception:
pass
# ── 后台自动清理线程 ──────────────────────────────────────────────
def _start_cleanup_thread(self):
def _loop():
while True:
time.sleep(DEFAULT_CLEANUP_INTERVAL)
try:
self._do_cleanup()
except Exception:
pass
import threading
t = threading.Thread(target=_loop, daemon=True, name="dbv2-cleanup")
t.start()
def _do_cleanup(self):
table = self._ensure_logs_table()
cutoff = (
datetime.now(timezone.utc) - timedelta(days=DEFAULT_RETENTION_DAYS)
).isoformat()
with self.engine.connect() as conn:
result = conn.execute(
delete(table).where(table.columns["ts"] < cutoff)
)
conn.commit()
expired_deleted = result.rowcount
count = conn.execute(
select(func.count()).select_from(table)
).scalar() or 0
trimmed = 0
if count > DEFAULT_MAX_RECORDS:
excess = count - DEFAULT_MAX_RECORDS
cutoff_row = conn.execute(
select(table.columns["ts"])
.order_by(table.columns["ts"].asc())
.limit(1).offset(DEFAULT_MAX_RECORDS)
).fetchone()
if cutoff_row:
trim_result = conn.execute(
delete(table).where(table.columns["ts"] < cutoff_row[0])
)
conn.commit()
trimmed = trim_result.rowcount
if expired_deleted or trimmed:
self._log_internal(
"db", "INFO",
f"自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条"
)
# ═══════════════════════════════════════════════════════════════════════
# 入口
# ═══════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="dbv2 — pymsg 数据库服务")
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
parser.add_argument("--port", type=int, default=8005, help="监听端口")
args = parser.parse_args()
server = DBServer(args.addr, args.port)
try:
server.start()
except KeyboardInterrupt:
server.stop()
+184
View File
@@ -0,0 +1,184 @@
"""
统一日志客户端 SDK — 各服务复制即用。
设计要点:
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
复制到目标服务目录后,按需修改 service_name 即可::
from log_client import logs
logs.info("dnfs", "节点新增成功 id=42")
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
logs.push("gateway", "WARNING", "登录失败次数超限",
trace_id="req-abc", extra={"ip": "1.2.3.4"})
"""
import json
import os as _os
import sys as _sys
import threading
import queue
import time
import atexit
from datetime import datetime, timezone
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
from pymsg import MsgClient # noqa: E402
# ── 配置 ──────────────────────────────────────────────────────────────
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
_BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
_TIMEOUT = 5 # socket 超时(秒)
class LogClient:
"""异步批量日志推送客户端。
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
service_name 用于在 dbv2 连接中标识来源,各服务复制时自行修改。
"""
def __init__(self, service_name="example",
batch_size=_BATCH_SIZE,
flush_interval=_FLUSH_INTERVAL):
self._service_name = service_name
self._batch_size = batch_size
self._flush_interval = flush_interval
self._queue = queue.Queue()
self._stop = threading.Event()
self._flush_signal = threading.Event()
self._thread = threading.Thread(target=self._flush_loop,
daemon=True, name='log-client')
self._thread.start()
atexit.register(self._shutdown)
# ── 公开接口 ────────────────────────────────────────────────────────
def push(self, service, level, message, trace_id=None, extra=None):
"""推送一条日志(非阻塞,立即返回)。"""
self._queue.put({
'service': service,
'level': level,
'message': message,
'trace_id': trace_id,
'extra': extra,
})
def debug(self, service, message, **kw):
self.push(service, 'DEBUG', message, **kw)
def info(self, service, message, **kw):
self.push(service, 'INFO', message, **kw)
def warning(self, service, message, **kw):
self.push(service, 'WARNING', message, **kw)
def error(self, service, message, **kw):
self.push(service, 'ERROR', message, **kw)
def critical(self, service, message, **kw):
self.push(service, 'CRITICAL', message, **kw)
def flush(self):
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
time.sleep(0.6) # 等待后台线程响应
batch = []
while True:
try:
batch.append(self._queue.get_nowait())
if len(batch) >= self._batch_size:
self._send(batch)
batch = []
except queue.Empty:
break
if batch:
self._send(batch)
self._flush_signal.clear()
# ── 内部实现 ────────────────────────────────────────────────────────
def _flush_loop(self):
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。"""
batch = []
last_flush = time.monotonic()
while not self._stop.is_set():
timeout = self._flush_interval - (time.monotonic() - last_flush)
chk_timeout = max(0.0, min(timeout, 0.5))
try:
item = self._queue.get(timeout=chk_timeout)
batch.append(item)
while len(batch) < self._batch_size:
try:
batch.append(self._queue.get_nowait())
except queue.Empty:
break
except queue.Empty:
pass
should_flush = (
len(batch) >= self._batch_size or
self._flush_signal.is_set() or
(batch and time.monotonic() - last_flush >= self._flush_interval)
)
if should_flush and batch:
self._send(batch)
batch = []
last_flush = time.monotonic()
# 退出前 flush 剩余
if batch:
self._send(batch)
def _send(self, batch):
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
now = datetime.now(timezone.utc).isoformat()
try:
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
for entry in batch:
extra_val = entry.get("extra")
payload = {
"_route": "execute",
"action": "insert",
"table": "Logs",
"values": {
"ts": now,
"service": entry.get("service"),
"level": entry.get("level"),
"message": entry.get("message"),
"trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False)
if extra_val is not None else None,
},
}
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
resp_obj = json.loads(resp)
if not resp_obj.get("success"):
import sys
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
file=sys.stderr)
except Exception as e:
import sys
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
def _shutdown(self):
"""进程退出时 flush 剩余日志。"""
self._stop.set()
self.flush()
# ── 全局单例 ──────────────────────────────────────────────────────────
# 各服务复制时,把 "example" 改为自己的服务名即可。
# 例如 dnfs/log_client.py → logs = LogClient(service_name="dnfs")
# 例如 smtp/log_client.py → logs = LogClient(service_name="smtp")
logs = LogClient()
+17 -38
View File
@@ -16,7 +16,7 @@ import threading
import time
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, current_app
from sqlalchemy import (
Table, insert, select, delete, func, and_, or_, MetaData
)
@@ -120,7 +120,6 @@ def init_log_routes(app, engine, metadata,
max_records=DEFAULT_MAX_RECORDS,
cleanup_interval=DEFAULT_CLEANUP_INTERVAL):
"""把日志蓝图注册到 Flask app,并注入 engine/metadata。"""
print('[logs] 正在初始化日志模块 ...')
app.config['LOG_ENGINE'] = engine
app.config['LOG_METADATA'] = metadata
@@ -130,37 +129,17 @@ def init_log_routes(app, engine, metadata,
# 建表 + 反射
try:
table = _ensure_logs_table(metadata, engine)
with engine.connect() as conn:
count = conn.execute(
select(func.count()).select_from(table)
).scalar() or 0
print(f'[logs] Logs 表就绪,当前 {count} 条日志')
_ensure_logs_table(metadata, engine)
except Exception as e:
print(f'[logs] 警告: Logs 表初始化失败: {e}')
log_internal(engine, metadata, 'db', 'ERROR',
f'Logs 表初始化失败: {e}')
# 注册路由
app.register_blueprint(bp)
routes = [
'POST /log/ingest',
'POST /log/query',
'POST /log/stats',
'POST /log/purge',
'GET /log/health',
]
for r in routes:
print(f'[logs] 路由注册: {r}')
# 配置摘要
print(f'[logs] 保留策略: {retention_days} 天 / 上限 {max_records} 条')
print(f'[logs] 自动清理: 每 {cleanup_interval} 秒执行一次')
# 启动后台清理线程
_start_cleanup_thread(engine, metadata,
retention_days, max_records, cleanup_interval)
print('[logs] 后台清理线程已启动')
print('[logs] 日志模块初始化完成')
# ── 5.1 推送日志 ──────────────────────────────────────────────────────
@@ -181,8 +160,8 @@ def ingest():
'error': f'单次最多 {MAX_BATCH} 条,当前 {len(logs)} 条'
}), 400
engine = app.config['LOG_ENGINE']
metadata = app.config['LOG_METADATA']
engine = current_app.config['LOG_ENGINE']
metadata = current_app.config['LOG_METADATA']
try:
table = _ensure_logs_table(metadata, engine)
@@ -252,8 +231,8 @@ def query():
"""结构化查询日志。"""
data = request.get_json(silent=True) or {}
engine = app.config['LOG_ENGINE']
metadata = app.config['LOG_METADATA']
engine = current_app.config['LOG_ENGINE']
metadata = current_app.config['LOG_METADATA']
try:
table = _ensure_logs_table(metadata, engine)
@@ -353,8 +332,8 @@ def stats():
"""日志分类统计。"""
data = request.get_json(silent=True) or {}
engine = app.config['LOG_ENGINE']
metadata = app.config['LOG_METADATA']
engine = current_app.config['LOG_ENGINE']
metadata = current_app.config['LOG_METADATA']
try:
table = _ensure_logs_table(metadata, engine)
@@ -416,8 +395,8 @@ def purge():
if not before:
return jsonify({'success': False, 'error': '缺少 before 字段'}), 400
engine = app.config['LOG_ENGINE']
metadata = app.config['LOG_METADATA']
engine = current_app.config['LOG_ENGINE']
metadata = current_app.config['LOG_METADATA']
try:
table = _ensure_logs_table(metadata, engine)
@@ -445,8 +424,8 @@ def purge():
@bp.route('/health', methods=['GET'])
def health():
"""日志模块健康检查。"""
engine = app.config['LOG_ENGINE']
metadata = app.config['LOG_METADATA']
engine = current_app.config['LOG_ENGINE']
metadata = current_app.config['LOG_METADATA']
try:
table = _ensure_logs_table(metadata, engine)
@@ -506,6 +485,6 @@ def _do_cleanup(engine, metadata, retention_days, max_records):
conn.commit()
trimmed = trim_result.rowcount
print(f'[logs] 自动清理完成: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条')
log_internal(engine, metadata, 'db', 'INFO',
f'自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条')
if expired_deleted or trimmed:
log_internal(engine, metadata, 'db', 'INFO',
f'自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条')
-172
View File
@@ -1,172 +0,0 @@
import os
from flask import Flask, request, jsonify, g
from sqlalchemy import (
create_engine, MetaData, Table, select, insert, update, delete, text
)
from sqlalchemy.exc import SQLAlchemyError
from waitress import serve
from security_middleware import apply_security_headers
from log_module import init_log_routes, log_internal
app = Flask(__name__)
apply_security_headers(app)
# 数据库配置
DATABASE_URL = os.environ.get('DATABASE_URL', 'sqlite:///database.db')
engine = create_engine(DATABASE_URL)
# 缓存反射的元数据(避免每次请求都反射)
metadata = MetaData()
# 注册日志模块(/log/* 路由 + 后台清理线程)
init_log_routes(app, engine, metadata)
def get_table(table_name):
"""反射表对象(若已缓存则直接返回)"""
if table_name not in metadata.tables:
Table(table_name, metadata, autoload_with=engine)
return metadata.tables[table_name]
def build_where(table, conditions):
"""将条件字典转换为 SQLAlchemy 条件表达式"""
if not conditions:
return None
clauses = []
for col, val in conditions.items():
column = table.columns[col]
if isinstance(val, dict):
# 支持多种比较操作
if 'eq' in val:
clauses.append(column == val['eq'])
if 'ne' in val:
clauses.append(column != val['ne'])
if 'gt' in val:
clauses.append(column > val['gt'])
if 'lt' in val:
clauses.append(column < val['lt'])
if 'ge' in val:
clauses.append(column >= val['ge'])
if 'le' in val:
clauses.append(column <= val['le'])
if 'like' in val:
clauses.append(column.like(val['like']))
if 'in' in val:
clauses.append(column.in_(val['in']))
else:
# 直接等值比较
clauses.append(column == val)
from sqlalchemy import and_
return and_(*clauses)
@app.route('/execute', methods=['POST'])
def execute_structured():
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': '请求体不能为空'}), 400
action = data.get('action', 'select')
table_name = data.get('table')
if not table_name:
return jsonify({'success': False, 'error': '缺少 table 字段'}), 400
try:
table = get_table(table_name)
except Exception as e:
return jsonify({'success': False, 'error': f'表加载失败: {str(e)}'}), 400
conn = None
try:
conn = engine.connect()
result = []
if action == 'select':
columns = data.get('columns', None)
if columns:
cols = [table.columns[c] for c in columns]
else:
cols = [table] # select *
stmt = select(*cols)
where_cond = build_where(table, data.get('where'))
if where_cond is not None:
stmt = stmt.where(where_cond)
# 排序
order_by = data.get('order_by')
if order_by:
for item in order_by:
col = table.columns[item['column']]
stmt = stmt.order_by(col.desc() if item.get('desc') else col.asc())
# 分页
limit = data.get('limit')
if limit is not None:
stmt = stmt.limit(limit)
offset = data.get('offset')
if offset is not None:
stmt = stmt.offset(offset)
res = conn.execute(stmt)
rows = res.fetchall()
result = [dict(row._mapping) for row in rows]
elif action == 'insert':
values = data.get('values', {})
stmt = insert(table).values(**values)
res = conn.execute(stmt)
conn.commit()
result = [{'lastrowid': res.lastrowid}] if hasattr(res, 'lastrowid') else []
elif action == 'update':
values = data.get('values', {})
where_cond = build_where(table, data.get('where'))
stmt = update(table).values(**values)
if where_cond is not None:
stmt = stmt.where(where_cond)
res = conn.execute(stmt)
conn.commit()
result = [{'rowcount': res.rowcount}]
elif action == 'delete':
where_cond = build_where(table, data.get('where'))
stmt = delete(table)
if where_cond is not None:
stmt = stmt.where(where_cond)
res = conn.execute(stmt)
conn.commit()
result = [{'rowcount': res.rowcount}]
else:
return jsonify({'success': False, 'error': f'不支持的操作: {action}'}), 400
# 记录操作日志(同进程直接写库,无 HTTP 往返,无循环依赖)
log_internal(engine, metadata, 'db', 'INFO',
f'{action} {table_name} OK',
extra={'rowcount': len(result) if isinstance(result, list) else 0,
'client_ip': request.remote_addr})
return jsonify({'success': True, 'data': result})
except SQLAlchemyError as e:
if conn:
conn.rollback()
log_internal(engine, metadata, 'db', 'ERROR',
f'{action} {table_name} FAILED: {e}',
extra={'client_ip': request.remote_addr})
return jsonify({'success': False, 'error': str(e)}), 400
except Exception as e:
if conn:
conn.rollback()
log_internal(engine, metadata, 'db', 'ERROR',
f'{action} {table_name} EXCEPTION: {e}',
extra={'client_ip': request.remote_addr})
return jsonify({'success': False, 'error': str(e)}), 400
finally:
if conn:
conn.close()
if __name__ == '__main__':
# 日志模块的初始化输出在 init_log_routes 调用时已完成
log_internal(engine, metadata, 'db', 'INFO',
'服务启动,监听 http://127.0.0.1:8005')
# 单线程单进程,绑定 127.0.0.1
serve(app, host='127.0.0.1', port=8005, threads=1)
+1
View File
@@ -2,3 +2,4 @@ nuitka
flask
SQLAlchemy
waitress
../pymsg
+115 -67
View File
@@ -1,38 +1,67 @@
# DNFS — 树状网络文件系统
# dnfs — 树状网络文件系统
Dendrachy Network File-System,基于父子关系(pid)组织的树状节点服务。
监听 `127.0.0.1:8006`,提供节点的增删改查,支持文件以 Base64 编码形式存储在节点 content 中。
监听 `127.0.0.1:8006`,提供节点的增删改查,支持以 Base64 编码形式在 `content` 中存储文件数据。所有写操作需携带由 gateway 签发的 Token。
## 架构
```
客户端 (static/dnfs.html)
│
│ Bearer Token
▼
dnfs :8006 (Flask + Waitress)
│
├──► db :8005 数据持久化(SQL-over-HTTP)
└──► ai_review :8007 内容审核(仅 add_note 时调用)
├──► db :8005 数据持久化(SQL-over-HTTP,/execute)
└──► ai_review :8007 内容审核(仅 add_note / update_note 时调用)
```
dnfs 不再直连数据库,所有数据操作通过 HTTP 调用 `db` 服务(`http://127.0.0.1:8005/execute`)完成。新增节点前会调用 `ai_review` 服务(`http://127.0.0.1:8007/review`)进行内容审核,审核不通过则拒绝写入。
- 不直连数据库,所有数据操作通过 HTTP 调用 `db` 服务(`http://127.0.0.1:8005/execute`)。
- 新增/更新节点前调用 `ai_review` 服务(`http://127.0.0.1:8007/review`)进行内容审核,审核不通过则拒绝写入。
- 通常部署在 gateway 之后(`Proxy.opened` 白名单中),由 gateway 完成 Token 验签后转发,dnfs 仅做所有权校验。
## 数据模型
对应 `db` 服务中的 `Notes` 表(见 [db/create_sqlite3.sql](../db/create_sqlite3.sql)):
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER PK | 节点唯一标识 |
| pid | INTEGER NOT NULL | 父节点 id,0 表示根 |
| owner | VARCHAR(128) | 创建者账号(来自 Token payload 的 `user` 字段,默认 `'Unknow'`) |
| title | VARCHAR(128) NOT NULL | 节点标题 |
| content | TEXT NOT NULL | 节点内容(可含 Base64 文件数据) |
## 鉴权与所有权
写操作(`add_note` / `delete_note` / `update_note`)需在请求头携带:
```
Authorization: Bearer <base64_payload>_<rsa_sign>
```
- Token 由 gateway 在 `/login` 签发,dnfs 通过 [dnfs.py:163](dnfs.py#L163) `decode_token` 仅解 Base64 取 payload,**不验签**(验签由 gateway 完成)。
- payload 中取 `user` 字段作为操作者,写入 `owner`。
- `delete_note` 与 `update_note` 校验所有权:
- 节点 `owner` 必须等于当前 `user`,否则 `403`;
- **例外**:`user == 'admin'` 可删除任意节点(管理员豁免,仅 delete)。
- `get_notes` 不需要鉴权。
## 接口文档
所有接口均为 `POST`,Content-Type 为 `application/json`。
所有接口均为 `POST`,`Content-Type: application/json`。
---
### POST /get_notes
获取指定父节点下的子节点列表。
获取指定父节点下的子节点列表。**无需鉴权**。
**Request**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| pid | int | 否 | 父节点 id,0 表示根节点,缺省默认 0 |
| pid | int | 否 | 父节点 id,0 表示根,缺省默认 0 |
```bash
curl -X POST http://localhost:8006/get_notes \
@@ -40,66 +69,58 @@ curl -X POST http://localhost:8006/get_notes \
-d '{"pid": 0}'
```
**Response 200**
直接返回节点数组:
**Response 200** — 直接返回节点数组:
```json
[
{"id": 1, "pid": 0, "title": "根节点", "content": "hello"},
{"id": 2, "pid": 0, "title": "另一个", "content": "world"}
{"id": 1, "pid": 0, "owner": "admin", "title": "根节点", "content": "hello"},
{"id": 2, "pid": 0, "owner": "alice", "title": "另一个", "content": "world"}
]
```
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 节点唯一标识 |
| pid | int | 父节点 id |
| title | str | 节点标题 |
| content | str | 节点内容(可能含 Base64 文件数据) |
空目录返回 `[]`。
---
### POST /add_note
添加新节点。流程:校验参数 → 校验父节点存在 → AI 内容审核 → 写入数据库。
添加新节点。流程:校验参数 → 校验 Token → 校验父节点存在 → AI 内容审核 → 写入数据库(含 owner)。
**Request**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| pid | int | 是 | 父节点 id,0 为根节点,非 0 时校验父节点是否存在 |
| pid | int | 是 | 父节点 id,0 为根,非 0 时校验父节点是否存在 |
| title | str | 是 | 节点标题,不可为空 |
| content | str | 是 | 节点内容,不可为空(可含 Base64 文件数据) |
请求头需带 `Authorization: Bearer <token>`。
```bash
curl -X POST http://localhost:8006/add_note \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"pid": 0, "title": "新节点", "content": "节点内容"}'
```
**Response 200** — 成功
**响应**
```json
{"success": true}
```
**Response 400** — 失败
| 场景 | 响应体 |
|------|--------|
| 缺少必填字段 | `{"error": "Pid, title and content are required"}` |
| 父节点不存在(pid≠0) | `{"error": "the new node need a parent"}` |
| AI 内容审核不通过 | `{"error": "review failed"}` |
| 数据库写入失败 | `{"success": false}` |
| 状态码 | 场景 | 响应体 |
|--------|------|--------|
| 200 | 成功 | `{"success": true}` |
| 400 | 缺少必填字段 | `{"error": "Pid, title and content are required"}` |
| 400 | 父节点不存在(pid≠0) | `{"error": "the new node need a parent"}` |
| 400 | AI 内容审核不通过 | `{"error": "review failed"}` |
| 400 | 数据库写入失败 | `{"success": false}` |
| 401 | 未携带 Token 或 Token 解析失败 | `{"error": "Warning: no Authorization"}` |
---
### POST /delete_note
删除指定节点。存在子节点时拒绝删除(保护树结构完整性)。
删除指定节点。流程:校验参数 → 校验 Token → 检查子节点 → 校验所有权 → 删除。
存在子节点时拒绝删除(保护树结构完整性)。**admin 可删除任意节点**,普通用户仅能删除自己拥有的节点。
**Request**
@@ -107,31 +128,33 @@ curl -X POST http://localhost:8006/add_note \
|------|------|------|------|
| id | int | 是 | 要删除的节点 id |
请求头需带 `Authorization: Bearer <token>`。
```bash
curl -X POST http://localhost:8006/delete_note \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"id": 1}'
```
**Response 200** — 成功
**响应**
```json
{"success": true}
```
**Response 400** — 失败
| 场景 | 响应体 |
|------|--------|
| 缺少 id 字段 | `{"error": "Note ID is required"}` |
| 存在子节点 | `{"error": "this node has a child"}` |
| 数据库删除失败 | `{"success": false}` |
| 状态码 | 场景 | 响应体 |
|--------|------|--------|
| 200 | 成功 | `{"success": true}` |
| 400 | 缺少 id | `{"error": "Note ID is required"}` |
| 400 | 存在子节点 | `{"error": "this node has a child "}` |
| 400 | 数据库删除失败 | `{"success": false}` |
| 401 | 未携带 Token | `{"error": "Warning: no Authorization"}` |
| 403 | 非本人节点且非 admin | `{"error": "It's not your note"}` |
---
### POST /update_note
更新节点的标题、内容和父节点(可用于迁移节点到其他位置)。
更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。
仅节点 owner 可更新;update 路径下 **admin 不享豁免**(与 delete 不同)。
**Request**
@@ -142,27 +165,47 @@ curl -X POST http://localhost:8006/delete_note \
| title | str | 是 | 新标题,不可为空 |
| content | str | 是 | 新内容,不可为空 |
请求头需带 `Authorization: Bearer <token>`。
```bash
curl -X POST http://localhost:8006/update_note \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"id": 1, "pid": 2, "title": "新标题", "content": "新内容"}'
```
**Response 200** — 成功
**响应**
```json
{"success": true}
```
**Response 400** — 失败
| 场景 | 响应体 |
|------|--------|
| 缺少必填字段 | `{"error": "Invalid param"}` |
| 数据库更新失败 | `{"success": false}` |
| 状态码 | 场景 | 响应体 |
|--------|------|--------|
| 200 | 成功 | `{"success": true}` |
| 400 | 缺少必填字段 | `{"error": "Invalid param"}` |
| 400 | AI 内容审核不通过 | `{"error": "review failed"}` |
| 400 | 数据库更新失败 | `{"success": false}` |
| 401 | 未携带 Token | `{"error": "Warning: no Authorization"}` |
| 403 | 非本人节点 | `{"error": "It's not your note"}` |
---
## 安全特性
- **安全响应头**([security_middleware.py](security_middleware.py)):`X-Frame-Options: DENY`、`X-Content-Type-Options: nosniff`、`Referrer-Policy`、`Permissions-Policy`、CSP(`default-src 'self'`、`frame-ancestors 'none'`),并移除 `Server` 头。
- **Token 鉴权**:写操作强制校验,payload 取 `user` 字段。
- **所有权隔离**:普通用户只能改/删自己的节点。
- **内容审核**:写入与更新前调用 `ai_review`,色情/违禁/赌博/诈骗/暴力等内容会被拒绝。
## 配置
服务地址与上游依赖硬编码在 [dnfs.py](dnfs.py):
| 常量 | 值 | 说明 |
|------|----|------|
| `ADDR` / `PORT` | `127.0.0.1` / `8006` | 监听地址 |
| `DB_SERVER_URL` | `http://127.0.0.1:8005/execute` | db 服务 |
| `REVIEW_SERVER_URL` | `http://127.0.0.1:8007/review` | ai_review 服务 |
调用超时均为 10 秒。
## 前端
三文件架构:
@@ -175,7 +218,7 @@ static/js/script.js 交互逻辑
功能:节点树浏览、新增/编辑/删除节点、文件 Base64 上传与下载、节点迁移(移动到其他父节点下)。
> 注:首页路由 `@app.route('/')` 当前已注释,前端页面实际由 static 服务的 `dnfs.html` 承载。
> 注:首页路由 `@app.route('/')` 当前已注释,前端页面实际由 `static` 服务的 `dnfs.html` 承载。
## 依赖
@@ -186,16 +229,21 @@ waitress
nuitka # 可选,编译为独立二进制
```
运行时需要 `db`(:8005)和 `ai_review`(:8007)服务已启动。
> `requirements.txt` 仍列有 `flask_sqlalchemy`,但代码已不使用(SQLAlchemy 直连代码已注释),属遗留依赖。
运行时需要 `db`(:8005)和 `ai_review`(:8007)服务已启动;对外访问需经 gateway 转发并验签。
## 运行
```bash
bash _set.sh # 创建 venv + 安装依赖
bash _run.sh # 启动服务
bash _run.sh # 启动服务(waitress)
```
## 已知问题
## 已知问题与风险
- `delete_note` 中子节点检查逻辑有误:查询条件为 `where id = note_id`(查节点自身),应为 `where pid = note_id`(查子节点),导致任何节点都无法删除。
- `review_content` 返回值类型不一致:正常返回 `bool`,HTTPError 时返回 `dict`。
- **`decode_token` 未验签**([dnfs.py:167](dnfs.py#L167)):仅解 Base64 取 payload,存在 `# FIXME: 验证数字签名` 标记。当前安全性依赖"必须经 gateway 转发"这一部署假设;若 dnfs 被直接访问(绕过 gateway),任意伪造的 Token 都能通过。建议补 RSA 验签或在部署上确保仅 gateway 可达。
- **`review_content` 返回类型不一致**([dnfs.py:73-119](dnfs.py#L73)):正常路径返回 `bool`([L105](dnfs.py#L105)),HTTPError 路径返回 `dict`([L113](dnfs.py#L113))。调用处用 `True != review` 判断,HTTPError 时 `dict != True` 恒为真,会一律判为审核失败并吞掉具体错误信息。建议统一返回类型或在调用处 catch 异常。
- **CORS 全开**:`CORS(app)` 未配置白名单,允许任意 Origin 跨域访问。考虑到写操作需 Token,风险可控,但仍建议按 gateway 的 `[CORS] allowed_origins` 方式收紧。
- **`call_db_service` 异常未捕获**:调用处([L208](dnfs.py#L208)、[L261](dnfs.py#L261)、[L318](dnfs.py#L318)、[L347](dnfs.py#L347)、[L440](dnfs.py#L440))直接取 `result['success']` / `notes['data']`,若 db 服务返回非预期结构或网络异常会抛 500。
- **`get_notes` 不鉴权**:任何能访问 dnfs 的人都能枚举所有节点内容。若内容含敏感 Base64 文件,需在 gateway 层做路径级鉴权。
+83 -44
View File
@@ -27,47 +27,29 @@ import urllib.request
import urllib.error
from typing import Dict, Any
from security_middleware import apply_security_headers
from log_client import logs
DB_SERVER_URL = 'http://127.0.0.1:8005/execute'
def call_db_service(url: str, payload: dict, timeout: int = 10) -> dict:
'''
使用标准库向数据库服务发送 JSON 请求并返回解析后的响应。
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import MsgClient # noqa: E402
Args:
url: 服务端点 URL (例如 'http://127.0.0.1:8005/execute')
payload: 要发送的 JSON 数据字典
timeout: 请求超时秒数
Returns:
dict: 服务返回的 JSON 响应
Raises:
urllib.error.URLError: 网络连接错误
urllib.error.HTTPError: HTTP 状态错误 (4xx, 5xx)
json.JSONDecodeError: 响应不是有效的 JSON
'''
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(
url,
data=data,
headers={'Content-Type': 'application/json'},
method='POST'
)
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
"""通过 pymsg 短连接调用 dbv2 数据库服务。
自动注入 ``_route: "execute"``。
"""
payload = {**payload, "_route": "execute"}
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
raw = response.read().decode('utf-8')
return json.loads(raw)
except urllib.error.HTTPError as e:
# HTTP错误,尝试读取错误响应体
error_body = e.read().decode('utf-8', errors='replace')
raise urllib.error.HTTPError(
e.url, e.code, f'HTTP Error {e.code}: {error_body}', e.hdrs, e.fp
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
resp_text = client.call(json.dumps(payload), timeout=timeout)
return json.loads(resp_text)
except Exception as e:
raise ConnectionError(
f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}"
) from None
except urllib.error.URLError as e:
raise urllib.error.URLError(f'连接失败: {e.reason}') from None
REVIEW_SERVER_URL = 'http://127.0.0.1:8007/review'
def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
@@ -184,6 +166,7 @@ def add_note():
if pid is None or not title or not content:
logs.warning('dnfs', 'add_note 参数缺失', extra={'ip': request.remote_addr})
return jsonify({"error": "Pid, title and content are required"}), 400
info = None
@@ -193,6 +176,7 @@ def add_note():
info = decode_token(token)
if info is None:
logs.warning('dnfs', 'add_note 未授权访问', extra={'ip': request.remote_addr})
return jsonify({"error": "Warning: no Authorization"}), 401
if pid != 0:
@@ -205,10 +189,11 @@ def add_note():
}
}
notes = call_db_service(DB_SERVER_URL, select_payload)
notes = _call_dbv2(select_payload)
# 不存在的父节点
if len( notes['data']) == 0:
logs.warning('dnfs', f'add_note 父节点不存在 pid={pid}', extra={'user': info['user']})
return jsonify({'error': 'the new node need a parent'}), 400
@@ -216,6 +201,7 @@ def add_note():
review = review_content(title + '\n' + content, 10)
if True != review:
#return jsonify({'error': 'review failed', 'data': review}), 400
logs.warning('dnfs', f'add_note 内容审核未通过 title={title}', extra={'user': info['user']})
return jsonify({'error': 'review failed'}), 400
insert_payload = {
@@ -229,13 +215,15 @@ def add_note():
}
}
result = call_db_service(DB_SERVER_URL, insert_payload)
result = _call_dbv2(insert_payload)
success = result['success']
if success is True:
logs.info('dnfs', f'add_note 成功 title={title} pid={pid}', extra={'user': info['user']})
return jsonify({'success': success}), 200
else:
logs.error('dnfs', f'add_note 写入失败 title={title}', extra={'user': info['user']})
return jsonify({'success': success}), 400
@@ -258,10 +246,11 @@ def get_notes():
}
}
notes = call_db_service(DB_SERVER_URL, select_payload)
notes = _call_dbv2(select_payload)
result = notes['data']
logs.info('dnfs', f'get_notes 查询 pid={pid} 返回{len(result)}条', extra={'ip': request.remote_addr})
return jsonify(result), 200
@@ -276,6 +265,7 @@ def delete_note():
if not note_id:
logs.warning('dnfs', 'delete_note 参数缺失 id', extra={'ip': request.remote_addr})
return jsonify({"error": "Note ID is required"}), 400
info = None
@@ -285,6 +275,7 @@ def delete_note():
info = decode_token(token)
if info is None:
logs.warning('dnfs', 'delete_note 未授权访问', extra={'ip': request.remote_addr})
return jsonify({"error": "Warning: no Authorization"}), 401
#with app.app_context():
@@ -315,11 +306,12 @@ def delete_note():
}
}
notes = call_db_service(DB_SERVER_URL, select_payload)
notes = _call_dbv2(select_payload)
# 有子节点的情况不可删除
if len(notes['data']) > 0:
logs.warning('dnfs', f'delete_note 存在子节点 id={note_id}', extra={'user': info['user']})
return jsonify({'error': 'this node has a child '}), 400
# 检查所有权
@@ -331,10 +323,11 @@ def delete_note():
}
}
data = call_db_service(DB_SERVER_URL, select_payload)
data = _call_dbv2(select_payload)
notes = data['data']
if not info['user'] == 'admin':
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
logs.warning('dnfs', f'delete_note 所有权拒绝 id={note_id}', extra={'user': info['user']})
return jsonify({"error": 'It\'s not your note'}), 403
@@ -344,16 +337,18 @@ def delete_note():
'where': {'id': note_id}
}
result = call_db_service(DB_SERVER_URL, delete_payload)
result = _call_dbv2(delete_payload)
result = result['success']
if result is True:
logs.info('dnfs', f'delete_note 成功 id={note_id}', extra={'user': info['user']})
return jsonify({'success': True}), 200
else:
logs.error('dnfs', f'delete_note 失败 id={note_id}', extra={'user': info['user']})
return jsonify({'success': False}), 400
@@ -379,6 +374,7 @@ def update_note():
if id is None or pid is None or not title or not content:
logs.warning('dnfs', 'update_note 参数缺失', extra={'ip': request.remote_addr})
return jsonify({"error": "Invalid param"}), 400
# 获取用户名
@@ -389,6 +385,7 @@ def update_note():
info = decode_token(token)
if info is None:
logs.warning('dnfs', 'update_note 未授权访问', extra={'ip': request.remote_addr})
return jsonify({"error": "Warning: no Authorization"}), 401
@@ -401,15 +398,17 @@ def update_note():
}
}
data = call_db_service(DB_SERVER_URL, select_payload)
data = _call_dbv2(select_payload)
notes = data['data']
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
return jsonify({"error": 'It\'s not your note'}), 403
# 内容审查
review = review_content(title + '\n' + content, 10)
if True != review:
#return jsonify({'error': 'review failed', 'data': review}), 400
logs.warning('dnfs', f'update_note 内容审核未通过 id={id} title={title}', extra={'user': info['user']})
return jsonify({'error': 'review failed'}), 400
#with app.app_context():
@@ -437,18 +436,57 @@ def update_note():
'where': {'id': id}
}
result = call_db_service(DB_SERVER_URL, update_payload)
result = _call_dbv2(update_payload)
result = result['success']
if result is True:
logs.info('dnfs', f'update_note 成功 id={id} title={title}', extra={'user': info['user']})
return jsonify({'success': True}), 200
else:
logs.error('dnfs', f'update_note 失败 id={id}', extra={'user': info['user']})
return jsonify({'success': False}), 400
## by shufeng
## 读取单个节点
'''@app.route("/get_note", methods=["POST"])
def get_note():
data = request.get_json()
id: int = data.get("id")
auth: str = request.headers.get("Authorization", "")
info = None
if auth.startswith("Bearer "):
token: str = auth[7:]
info = decode_token(token)
if id is None:
logs.error("dnfs", "get_note: 参数不足,缺少id")
return jsonify({"error": "Invalid param"}), 401
select_payload = {
"method": "select",
"tables": "Notes",
"where": {
"id": id
}
}
data = _call_dbv2(select_payload)
notes = data["data"]
if not len(notes) == 1 or not notes[0]["owner"] == info["user"]:
logs.error("dnfs", "没有读取权")
return jsonify({"error": "你没有读取这个的权力"}), 403
logs.info("dnfs", f"get_note 查询 id={id}")
return jsonify()
'''
@@ -460,6 +498,7 @@ def update_note():
ADDR='127.0.0.1'
PORT=8006
if __name__ == "__main__":
logs.info('dnfs', f'服务启动,监听 http://{ADDR}:{PORT}')
print(f'Listen at http://{ADDR}:{PORT}')
#app.run(host="127.0.0.1", port=8006)
serve(app, host='127.0.0.1', port=8006)
-144
View File
@@ -1,144 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>树状笔记</title>
<!-- ​Font Awesome 6.0.0 是矢量图标库,提供 7,000+ 免费图标及 17,000+ 专业图标。 -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<!-- 外部CSS文件 -->
<link rel="stylesheet" href="static/css/style.css">
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-sitemap"></i> 树状笔记</h1>
<div class="header-actions">
<button onclick="resetForm()" class="btn btn-primary"><i class="fas fa-plus"></i> 新增节点</button>
<button onclick="goBack()" class="btn btn-secondary"><i class="fas fa-arrow-left"></i> 返回上级</button>
</div>
</div>
<!-- 节点迁移模式提示栏 -->
<div id="move-mode-bar" class="move-mode-bar" style="display: none;">
<div class="move-mode-info">
<i class="fas fa-exchange-alt"></i>
正在迁移节点:<strong id="moving-node-title"></strong>
</div>
<div class="move-mode-actions">
<button onclick="confirmMoveHere()" class="btn btn-move-here"><i class="fas fa-arrow-down"></i> 移动到此</button>
<button onclick="cancelMove()" class="btn btn-cancel-move"><i class="fas fa-times"></i> 取消</button>
</div>
</div>
<div class="main-content">
<div class="form-section">
<!-- 表单标题 -->
<h3 class="form-title"><i class="fas fa-pen"></i> <span id="form-mode-text">新增节点</span></h3>
<input type="hidden" id="edit-id" value=""> <!-- 隐藏输入框,要编辑节点的id -->
<!-- 输入节点标题 -->
<div class="form-group">
<label for="node-title"><i class="fas fa-heading"></i> 标题</label>
<input type="text" id="node-title" class="form-control" placeholder="输入节点标题">
</div>
<!-- 输入节点内容 -->
<div class="form-group">
<label for="node-content"><i class="fas fa-file-alt"></i> 节点说明</label>
<textarea id="node-content" class="form-control" placeholder="输入节点的文字说明"></textarea>
</div>
<!-- 文件上传区域 -->
<div class="form-group">
<label><i class="fas fa-upload"></i> 文件上传</label>
<div class="file-upload-area" onclick="document.getElementById('file-upload').click()">
<i class="fas fa-cloud-upload-alt" style="font-size: 2em; color: #888; margin-bottom: 10px;"></i>
<p>点击或拖拽文件到此处上传</p>
<p style="font-size: 0.8em; color: #999;">文件将以编码形式保存,同一节点内数据不要超过1M</p>
<input type="file" id="file-upload" style="display: none;" onchange="handleFileUpload(this.files)"> <!-- 隐藏输入框 -->
</div>
<!-- 选择文件状态信息 -->
<div class="file-info" id="file-info">未选择文件</div>
<!-- 文件元信息显示 -->
<div id="file-meta-container" style="display: none;">
<div id="file-meta-info" class="file-meta-info">
<p><i class="fas fa-file"></i> 文件名:<span id="meta-filename"></span></p>
<p><i class="fas fa-hdd"></i> 文件大小:<span id="meta-filesize"></span></p>
<p><i class="fas fa-calendar-alt"></i> 上传时间:<span id="meta-uploadtime"></span></p>
</div>
</div>
<!-- 数据修复提示 -->
<div id="data-repair-hint" class="data-repair-hint" style="display: none;">
<i class="fas fa-exclamation-circle"></i>
<span>检测到文件元数据损坏。请尝试重新上传文件以修复此问题。</span>
</div>
<!-- 文件进度条 -->
<div class="file-progress">
<div class="file-progress-bar" id="file-progress-bar"></div>
</div>
</div>
<!-- 文件下载区域(仅在有文件时显示) -->
<div class="form-group" id="download-section" style="display: none;">
<label><i class="fas fa-download"></i> 文件操作</label>
<div style="display: flex; gap: 8px;">
<button onclick="decodeAndDownload(null, null, true)" class="btn btn-success">
<i class="fas fa-download"></i> 下载文件
</button>
<button onclick="removeFile()" class="btn btn-danger">
<i class="fas fa-trash"></i> 移除文件
</button>
</div>
</div>
<!-- 隐藏的Base64存储字段 - 用户永远不会看到 -->
<textarea id="hidden-base64" class="form-control hidden-field" placeholder="Base64编码内容(自动生成,隐藏显示)"></textarea>
<input type="hidden" id="file-metadata" value="">
<!-- 动作表单 -->
<div class="form-actions">
<button onclick="saveNode()" class="btn btn-primary"><i class="fas fa-save"></i> 保存</button>
<button onclick="resetForm()" class="btn btn-secondary"><i class="fas fa-undo"></i> 重置</button>
</div>
</div>
<!-- 节点表单 -->
<div id="node-list" class="node-list">
<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>
</div>
</div>
</div>
<!-- 删除确认模态框 -->
<div id="delete-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title"><i class="fas fa-exclamation-triangle"></i> 确认删除</h3>
<button class="modal-close" onclick="closeDeleteModal()"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<p id="delete-message">确定删除该节点吗?</p>
<p id="delete-node-title-container" style="font-weight: bold; margin: 8px 0;"></p>
<p id="delete-file-hint" style="font-size:.85em;color:#e74c3c;display:none;"><i class="fas fa-exclamation-circle"></i> 此节点包含文件,删除后文件将无法恢复</p>
<p style="font-size:.85em;color:#777">此操作不可恢复</p>
</div>
<div class="modal-footer">
<button onclick="confirmDelete()" class="btn btn-danger"><i class="fas fa-trash"></i> 删除</button>
<button onclick="closeDeleteModal()" class="btn btn-secondary"><i class="fas fa-times"></i> 取消</button>
</div>
</div>
</div>
<div id="toast-container"></div>
<!-- 外部JS文件 -->
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
</body>
</html>
+50 -28
View File
@@ -2,7 +2,7 @@
统一日志客户端 — 各服务共用。
设计要点:
- 使用 urllib.request(标准库,不引入 requests)
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
@@ -17,32 +17,37 @@
"""
import json
import os as _os
import sys as _sys
import threading
import queue
import time
import urllib.request
import urllib.error
import atexit
from datetime import datetime, timezone
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
from pymsg import MsgClient # noqa: E402
# ── 配置 ──────────────────────────────────────────────────────────────
DB_LOG_URL = 'http://127.0.0.1:8005/log/ingest'
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
_BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
_TIMEOUT = 5 # HTTP 请求超时(秒)
_TIMEOUT = 5 # socket 超时(秒)
class LogClient:
"""异步批量日志推送客户端。"""
def __init__(self, url=DB_LOG_URL, batch_size=_BATCH_SIZE,
def __init__(self, batch_size=_BATCH_SIZE,
flush_interval=_FLUSH_INTERVAL):
self._url = url
self._batch_size = batch_size
self._flush_interval = flush_interval
self._queue = queue.Queue()
self._stop = threading.Event()
self._flush_signal = threading.Event()
self._thread = threading.Thread(target=self._flush_loop,
daemon=True, name='log-client')
self._thread.start()
@@ -76,7 +81,9 @@ class LogClient:
self.push(service, 'CRITICAL', message, **kw)
def flush(self):
"""手动 flush 队列中剩余的日志。"""
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
time.sleep(0.6) # 等待后台线程响应
batch = []
while True:
try:
@@ -88,6 +95,7 @@ class LogClient:
break
if batch:
self._send(batch)
self._flush_signal.clear()
# ── 内部实现 ────────────────────────────────────────────────────────
@@ -97,25 +105,25 @@ class LogClient:
last_flush = time.monotonic()
while not self._stop.is_set():
timeout = max(0, self._flush_interval - (time.monotonic() - last_flush))
timeout = self._flush_interval - (time.monotonic() - last_flush)
chk_timeout = max(0.1, min(timeout, 0.5))
try:
item = self._queue.get(timeout=timeout)
item = self._queue.get(timeout=chk_timeout)
batch.append(item)
# 继续非阻塞地取,直到满 batch_size
while len(batch) < self._batch_size:
try:
batch.append(self._queue.get_nowait())
except queue.Empty:
break
except queue.Empty:
pass # 超时,没有新日志
pass
# 满了或超时了就发
should_flush = (
len(batch) >= self._batch_size or
self._flush_signal.is_set() or
(batch and time.monotonic() - last_flush >= self._flush_interval)
)
if should_flush:
if should_flush and batch:
self._send(batch)
batch = []
last_flush = time.monotonic()
@@ -125,21 +133,35 @@ class LogClient:
self._send(batch)
def _send(self, batch):
"""发送一批日志到 db 服务。失败时静默丢弃。"""
data = json.dumps({'logs': batch}).encode('utf-8')
req = urllib.request.Request(
self._url, data=data,
headers={'Content-Type': 'application/json'},
method='POST'
)
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
now = datetime.now(timezone.utc).isoformat()
try:
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
resp.read() # 消费响应体
except (urllib.error.URLError, urllib.error.HTTPError, OSError):
# fail-safe:日志服务不可用,静默丢弃,不影响业务
pass
except Exception:
pass
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs-log")
for entry in batch:
extra_val = entry.get("extra")
payload = {
"_route": "execute",
"action": "insert",
"table": "Logs",
"values": {
"ts": now,
"service": entry.get("service"),
"level": entry.get("level"),
"message": entry.get("message"),
"trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False)
if extra_val is not None else None,
},
}
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
resp_obj = json.loads(resp)
if not resp_obj.get("success"):
import sys
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
file=sys.stderr)
except Exception as e:
import sys
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
def _shutdown(self):
"""进程退出时 flush 剩余日志。"""
+1
View File
@@ -4,3 +4,4 @@ flask-cors
flask_sqlalchemy
#gunicorn
waitress
../pymsg
-504
View File
@@ -1,504 +0,0 @@
* { /* 通配符选择器,表示匹配页面中所有 HTML 元素 */
margin:0;
padding:0;
box-sizing:border-box
}
body { /* 针对 <body> 元素的样式设置,主要用于定义页面的整体基础样式 */
font-family:system-ui,sans-serif; /* 页面的默认字体。system-ui 表示优先使用操作系统的默认系统字体 */
background:#f5f5f5; /* 设置页面的背景色为浅灰色(#f5f5f5),这是一种常见的浅色调背景,既能与内容形成柔和对比,又不会像纯白色(#fff)那样刺眼,提升长时间阅读的舒适度。 */
min-height:100vh; /* 最小高度为视口高度。即使页面内容较少(不足以占满整个屏幕),背景色也能铺满整个视口,避免底部出现空白区域,保证页面视觉完整性。 */
padding:10px /* 给 <body> 元素设置内边距为 10px,使页面内容与浏览器窗口边缘保持一定距离,避免内容紧贴屏幕边框,提升布局的呼吸感和美观度。 */
}
.container { /* 针对类名为 container 的元素设置的样式,通常通常用于包裹页面主要内容的容器 */
max-width:800px; /* 限制容器的最大宽度为 800px。作用是避免内容在大屏幕上过度拉伸,保证文字阅读的舒适行宽(过宽的文本行不利于阅读) */
margin:0 auto; /* 设置容器的上下外边距为 0,左右外边距为 auto(自动计算),这是让块级元素在父容器中水平居中的经典写法。 */
background:#fff; /* 设置容器的背景色为纯白色(#fff),与前面 <body> 设置的浅灰色(#f5f5f5)背景形成对比,突出容器内的内容区域。 */
border-radius:6px; /* 给容器添加 6px 的圆角边框,使容器边角从直角变为圆弧,增加视觉柔和度,提升设计感。 */
box-shadow:0 1px 3px #ddd /* 为容器添加轻微的阴影效果:水平偏移 0、垂直偏移 1px、模糊半径 3px,阴影颜色为浅灰色(#ddd)。 */
} /* 作用是让容器在页面中产生轻微的 "悬浮" 感,与背景区分开,增强页面的层次感。 */
.header { /* 定义类名为 .header 的元素样式,通常适用于网页的头部导航栏或标题区域 */
background:#888; /* 设置元素的背景色为深灰色(#888),这是一种中性深色,适合作为头部背景以突出内容。 */
color:#fff; /* 定义元素内文字的颜色为纯白色(#fff),与深灰色背景形成强烈对比,确保文字清晰可读 */
padding:12px; /* 为元素设置内边距为 12px,使头部内容(如标题、菜单等)与边框保持一定距离,避免内容紧贴边缘,提升视觉舒适度。 */
display:flex; /* 将元素设置为 Flex 布局容器,使其内部子元素可以使用 Flexbox 的特性进行灵活排列,这是现代网页布局中常用的高效方式。 */
justify-content:space-between; /* 控制 Flex 容器中子元素在水平方向(主轴) 的对齐方式:子元素会沿水平方向分布,两端的子元素分别靠向容器的左右边缘,中间的剩余空间均匀分配在子元素之间。 */
align-items:center /* 控制 Flex 容器中子元素在垂直方向(交叉轴) 的对齐方式:所有子元素会在垂直方向上居中对齐,确保不同高度的元素(如文字和图标)在视觉上保持水平一致。 */
}
.header h1 { /* 用于设置类名为 .header 内部的 <h1> 标题元素的样式 */
font-size:1.4em /* 定义该 <h1> 标题的字体大小为 1.4em。 其中 em 是相对单位,基于父元素的字体大小进行计算(1em = 父元素字体大小)。 */
}
.header-actions { /* 用于定义类名为 .header-actions 的元素样式,该元素通常是页面头部(.header)中存放操作按钮、图标等交互元素的容器 */
display:flex; /* 设置为 Flex 布局容器,使其内部的子元素(如按钮、图标)能够沿水平方向(默认主轴方向)排列,且可通过 Flex 特性灵活控制子元素的对齐和分布,适配头部区域的横向布局需求。 */
gap:6px /* 为 Flex 容器内的子元素之间设置 6px 的间距,包括水平和垂直方向(若子元素换行)。作用是避免子元素(如 “编辑”“删除” 按钮、功能图标)紧贴在一起,让布局更宽松、清晰,同时无需手动给每个子元素设置 margin,简化样式代码。 */
}
.main-content { /* 定义类名为 .main-content 的元素样式,通常该元素是页面中承载主要内容(如文章、列表、表单等)的容器 */
padding:15px /* 为 .main-content 元素设置内边距为 15px,即元素内部的内容与元素边框(或边缘)之间保持 15px 的距离。 */
}
.form-section { /* 用于定义类名为 .form-section 的元素样式,从命名和样式特征来看,这个类通常用于包裹表单相关内容的区域 */
background:#f5f5f5; /* 设置背景色为浅灰色(#f5f5f5),与周围可能的白色(#fff)内容区域形成轻微对比,从而视觉上区分出表单区域。 */
padding:15px; /* 为表单区域设置 15px 的内边距,让表单内的输入框、标签等元素与边框保持距离,避免内容拥挤,提升填写体验。 */
border-radius:4px; /* 给区域添加 4px 的圆角边框,使边角呈现柔和的圆弧效果,减少生硬感,增强视觉友好度。 */
border:1px dashed #ddd; /* 设置 1px 宽的虚线边框,颜色为浅灰色(#ddd)。虚线样式相比实线更显轻盈,既能勾勒出表单区域的边界,又不会过于抢眼,同时与浅灰色背景形成协调的视觉效果。 */
margin-bottom:15px /* 在表单区域底部设置 15px 的外边距,使该区域与下方的其他内容保持距离,避免不同内容区块紧贴在一起,增强页面布局的层次感。 */
}
.form-title { /* 用于定义类名为 .form-title 的元素样式,从命名来看,这通常是表单区域的标题样式设置 */
color:#555; /* 设置标题文字颜色为中灰色(#555),相比纯黑色(#000)更柔和,既保证可读性,又不会过于抢眼,适合作为区域标题的视觉表现 */
margin-bottom:12px; /* 在标题底部设置 12px 的外边距,使标题与下方的表单内容(如输入框、按钮等)保持距离,形成清晰的内容分隔,提升表单区域的层次感。 */
font-size:1.1em; /* 定义标题字体大小为 1.1em(相对单位),基于父元素字体大小计算(若父元素默认 16px,则约为 17.6px)。这个大小既比普通内容文字略大,突出标题属性,又不会过于夸张,保持整体协调。 */
display:flex; /* 将标题元素设置为 Flex 布局容器,使其内部可能包含的子元素(如图标和文字)能够横向排列。 */
align-items:center; /* 让 Flex 容器内的子元素(如标题前的图标和文字)在垂直方向上居中对齐,确保不同元素在视觉上保持水平一致。 */
gap:6px /* 为标题内的子元素(如图标和文字)之间设置 6px 的间距,避免元素紧贴,使布局更清晰。 */
}
.form-group { /* 用于定义类名为 .form-group 的元素样式,从命名来看,该类通常用于包裹表单中的一组相关元素(如一个输入框与其对应的标签) */
margin-bottom:12px /* 设置 .form-group 元素底部的外边距为 12px,即每个表单组与下方的其他表单组之间保持 12px 的距离 */
}
.form-group label { /* 用于设置 .form-group 容器内的 <label> 标签样式,主要针对表单中输入项的说明文字进行美化 */
display:block; /* 将 <label> 从默认的行内元素(inline)改为块级元素(block),使其独占一行,强制后续的输入框(如 <input>)另起一行显示,形成 “标签在上,输入框在下” 的经典表单布局,更符合用户阅读和填写习惯。 */
margin-bottom:4px; /* 在标签底部设置 4px 的外边距,使标签与下方的输入框保持微小距离,避免两者紧贴,提升视觉清晰度。 */
color:#555; /* 标签文字使用中灰色(#555),相比黑色更柔和,既保证可读性,又不会抢夺输入框的视觉焦点(表单中输入区域通常需要更突出)。 */
font-size:.9em /* 标签字体大小为 0.9em(相对单位),比默认文字略小(若父元素默认 16px,则约为 14.4px),通过尺寸差异进一步弱化标签的视觉权重,让用户注意力更易集中在输入区域。 */
}
.form-control { /* 这段这段这段 CSS 代码用于定义类名为 .form-control 的元素样式,这类元素通常通常是表单中的输入控件(如文本框、下拉框等) */
width:100%; /* 设置输入控件的宽度为父容器(通常是 .form-group)的 100%,使其填满满整个表单组的宽度,保证表单表单控件在水平方向上对齐统一,形成整齐的视觉效果。 */
padding:8px; /* 为控件控件内部设置 8px 的内边距,让输入的文字与边框保持一定距离,避免仅避免文字紧贴边框影响阅读,也增加了控件的可点击区域和视觉面积,提升操作体验。 */
border:1px solid #ddd; /* 给控件添加 1px 宽的实线边框,颜色为浅灰色(#ddd),既明确了控件的边界,又不会过于醒目,保持整体风格的简洁柔和。 */
border-radius:3px; /* 设置 3px 的圆角边框,使控件边角变得圆润,相比直角边框更亲和用户,与表单其他元素的圆角设计(如 .form-section 的 border-radius: 4px)形成呼应,保持持视觉一致性。 */
font-size:.9em /* 控件内文字大小为 0.9em(相对单位),与前面表单标签的字体大小保持一致,确保表单内文字尺寸的协调性,避免视觉混乱。 */
}
textarea.form-control { /* 针对同时具有 form-control 类的 <textarea> 元素(多行文本输入框)的样式补充,用于优化多行输入框的特定表现 */
resize:vertical; /* vertical 表示用户只能垂直(上下)拖动文本框边缘来改变其高度,而不能水平(左右)调整宽度。 */
min-height:120px; /* 设置文本框的最小高度为 120px,确保初始状态下有足够的可视区域供用户输入多行内容(如备注、描述等),避免默认状态下文本框过小影响使用。 */
line-height:1.5 /* 设置文本框内文字的行高为 1.5 倍字号,增加多行文本之间的垂直间距,使长文本更易阅读和编辑,提升可读性。 */
}
.form-actions { /* 用于定义类名为 .form-actions 的元素样式,从命名和样式来看,这个类通常用于包裹表单底部的操作按钮(如 “提交”“取消” 按钮) */
display:flex; /* 将 .form-actions 设为 Flex 布局容器,使内部的按钮元素能够沿水平方向排列,方便控制按钮间的间距和对齐方式。 */
gap:8px; /* 为容器内的按钮之间设置 8px 的间距,避免多个操作按钮紧贴在一起,使布局更清晰,操作区域更易区分。 */
justify-content:flex-end; /* 控制 Flex 容器内按钮在水平方向的对齐方式:将所有按钮靠右对齐。这是表单设计的常见做法,符合用户视觉习惯(操作按钮通常放在表单底部右侧)。 */
margin-top:15px /* 在操作按钮区域顶部设置 15px 的外边距,使按钮区域与上方的表单输入内容保持距离,形成明显的视觉分隔,让用户完成输入后能快速定位到操作按钮。 */
}
.node-list { /* 用于定义类名为 .node-list 的元素样式,从命名推测,该元素可能是用于展示一系列节点(如列表项、卡片、条目等)的容器 */
margin:15px 0 /* 设置 .node-list 元素的上下外边距为 15px,左右外边距为 0。 */
}
.node { /* 用于定义类名为 .node 的元素样式,从样式特征来看,这类元素通常是列表中的单个条目(如内容节点、信息卡片等) */
background:#fafafa; /* 设置节点的背景色为极浅的灰色。既能与周围区域形成微妙区分,又保持了整体的柔和基调*/
padding:15px; /* 为节点内部设置 15px 的内边距,让节点内的内容(如文本、图标等)与边框保持距离,避免拥挤,提升可读性 */
border-radius:4px; /* 给节点添加 4px 的圆角边框,使边角呈现柔和的圆弧效果,减少生硬感,与页面中其他元素(如表单区域)的圆角设计形成风格统一。 */
border-left:3px solid #888; /* 在节点左侧设置 3px 宽的深灰色(#888)实线边框,作为视觉标识。这种左侧边框设计常用于突出条目重要性或进行分类标记,能快速吸引用户注意力并形成视觉焦点。 */
box-shadow:0 1px 2px #eee; /* 为节点添加轻微的阴影效果(水平偏移 0、垂直偏移 1px、模糊半径 2px,颜色为极浅灰色 #eee),使节点产生轻微的悬浮感,增强与背景的层次感,但又不会过于抢眼。 */
margin-bottom:10px; /* 在节点底部设置 10px 的外边距,使列表中的多个节点之间保持适当距离,避免条目紧贴,保证列表的清晰可读性。 */
cursor:pointer /* 整张卡片可点击进入节点,显示手型光标 */
}
.node:hover { /* .node 元素的鼠标悬浮状态样式,用于在用户将鼠标指针移到节点条目上时,提供视觉反馈以增强交互体验 */
box-shadow:0 2px 4px #ddd /* 垂直偏移量从 1px 增加到 2px:阴影向下偏移更多,增强 “悬浮抬起” 的视觉感受;模糊半径从 2px 增加到 4px:阴影范围更扩散,让悬浮效果更明显; 阴影颜色从 #eee(极浅灰)加深到 #ddd(浅灰):阴影对比度提高,进一步强化视觉反馈。 */
}
/* 文件节点特殊样式 */
.node.has-file { /* 这是一个多类选择器,表示同时拥有 node 和 has-file 两个类的元素。 */
border-left-color: #2ecc71; /* 通过左侧边框的颜色差异,直观区分出 “包含文件的节点” 与普通节点,让用户能快速识别节点类型,提升信息扫描效率。 */
}
.node-title { /* 用于定义类名为 .node-title 的元素样式,从命名和样式来看,这通常是节点(.node)内部的标题部分 */
font-size:1.1em; /* 比节点内的普通文字略大,突出标题的层级地位,方便用户快速定位每个节点的核心信息 */
color:#333; /* 比普通内容文字的颜色更深,通过颜色对比差异强化标题的视觉权重,同时相比纯黑色(#000)更柔和,避免视觉刺眼 */
margin-bottom:8px; /* 使其与下方的节点内容(如描述文字、附加信息等)保持距离,形成清晰的内容层次 */
cursor:pointer; /* 当鼠标指针悬停在标题上时,光标会变成手型(指针样式),暗示该标题是可点击的(可能关联展开 / 折叠、跳转等交互功能),增强交互的直观性 */
display:flex; /* 使其内部元素(如图标和文字)能横向排列。 */
align-items:center; /* 让标题内的子元素(如图标和文字)在垂直方向上居中对齐,保证视觉上的整齐度。 */
gap:6px /* 避免元素紧贴,提升布局清晰度。 */
}
.node-content { /* 用于定义类名为 .node-content 的元素样式,从命名和样式来看,这通常是节点(.node)内部的正文内容区域 */
color:#666; /* 通过颜色差异区分内容层级,让标题更突出,同时保持内容的可读性 */
font-size:.9em; /* 进一步通过尺寸差异强化标题与内容的层级关系,使节点结构更清晰 */
margin-bottom:12px; /* 使其与节点内下方的其他元素(如操作按钮、附加信息等)保持距离,避免内容拥挤,提升整体布局的呼吸感 */
white-space:pre-wrap; /* 控制文本的换行和空格处理:保留文本中的换行符和空格,同时当内容超出容器宽度时自动换行。 */
line-height:1.5 /* 增加多行文本之间的垂直间距,使长文本更易阅读,减轻视觉疲劳,提升内容的可读性。 */
}
.file-indicator { /* 用于定义类名为 .file-indicator 的元素样式,从命名和样式来看,这通常是用于标识节点中包含文件的小标签或提示元素 */
display: inline-block; /* 将元素设置为行内块级元素,使其既可以像行内元素一样与其他内容(如文本)在同一行显示,又可以像块级元素一样设置宽高、内外边距等样式,适合作为小型标签使用 */
background: #e9f5e9; /* 与包含文件的节点左侧边框颜色(#2ecc71)相呼应,形成统一的视觉标识系统,直观关联 “文件” 这一特征。 */
padding: 3px 8px; /* 使标签文字与边框保持适当距离,既保证标签小巧紧凑,又避免文字过于拥挤。 */
border-radius: 3px; /* 使其外观更柔和,与节点整体的圆角设计风格统一。 */
font-size: 0.8em; /* 比节点内容文字更小,作为辅助信息不抢占主要内容的视觉焦点。 */
color: #27ae60; /* 与背景色形成柔和对比,保证文字清晰可读,同时与节点左侧的文件标识色属同一色系,强化 “文件” 相关的视觉关联 */
margin-top: 5px; /* 使其与上方的内容(如节点描述)保持微小距离,避免紧贴,提升布局清晰度。 */
}
.node-actions { /* 用于定义类名为 .node-actions 的元素样式,从命名和样式来看,该元素通常是节点(.node)内部的操作按钮容器(如 “编辑””删除””查看” 等按钮) */
display:flex; /* 使内部的操作按钮能够沿水平方向排列,方便控制按钮间的间距和对齐方式 */
gap:8px; /* 避免多个按钮紧贴在一起,使每个操作选项更易区分,提升点击准确性 */
justify-content:flex-end; /* 控制按钮在水平方向的对齐方式:将所有操作按钮靠右对齐。这是内容条目中操作区的常见布局,既不干扰左侧主要内容的阅读,又符合用户寻找操作按钮的视觉习惯(通常在条目右侧)。 */
cursor:default /* 操作区不跟随卡片的 pointer 光标,提示用户这里是按钮区 */
}
.btn { /* 定义了一个基础按钮(.btn)的通用样式,适用于页面中各类按钮元素,具体解析如下 */
padding:5px 10px; /* 使按钮保持紧凑但不拥挤的尺寸,既方便点击又不会占用过多空间。 */
border:none; /* 移除默认边框,避免浏览器自带的按钮边框样式干扰自定义设计,使按钮风格更统一 */
border-radius:3px; /* 让按钮边角呈现柔和效果,与页面中其他元素(如表单、节点)的圆角设计保持风格一致 */
cursor:pointer; /* 鼠标悬停时显示手型光标,明确提示该元素可点击,增强交互的直观性。 */
font-size:.85em; /* 比普通内容文字略小,保持按钮的紧凑感,同时保证文字清晰可读 */
display:flex; /* 使用 Flex 布局,使按钮内可能包含的图标和文字能够横向排列。 */
align-items:center; /* 让按钮内的图标和文字在垂直方向上居中对齐,确保视觉上的整齐度。 */
gap:3px /* 避免元素紧贴,提升按钮内部布局的清晰度。 */
}
.btn-primary { /* 用于强调页面中的重要操作按钮 */
background:#888; /* 与页面头部的背景色保持一致,形成视觉呼应,强化主要操作按钮的辨识度 */
color:#fff /* 与深灰色背景形成强烈对比,确保文字清晰可读,同时突出按钮的重要性 */
}
.btn-danger { /* 危险 / 警告类按钮样式,通常用于表示具有破坏性或重要警告性质的操作(如 “删除”“取消”“确认删除” 等) */
background:#e74c3c; /* 设置按钮背景色为鲜红色,这是国际通用的警告 / 危险色,能快速引起用户注意,暗示该操作可能具有不可逆的后果(如删除数据) */
color:#fff /* 文字颜色为纯白色,与鲜红色背景形成高对比度,确保文字清晰可读,同时强化视觉冲击力,进一步突出操作的特殊性。 */
}
.btn-secondary { /* 用于表示次要按钮(次要操作按钮),通常用于次要或辅助性的交互操作 */
background:#aaa; /* 按钮背景色为中灰色,比主要按钮更浅,视觉权重更低,通过颜色明暗差异直观体现操作的次要地位 */
color:#fff /* 文字仍为纯白色,与中灰色背景保持足够对比度以保证可读性,同时保持与其他按钮在文字样式上的一致性。 */
}
.btn-warning { /* 用于表示警告类按钮,通常用于需要用户注意但不及危险操作严重的场景(如 “修改”“覆盖” 等可能带来一定影响的操作) */
background:#f39c12; /* 按钮背景色为橙色,这是国际通用的警告色,视觉上比主要按钮(灰色)更醒目,但比危险按钮(红色)柔和,既提示用户关注操作,又不会造成过度紧张感 */
color:#fff /* 文字为纯白色,与橙色背景形成高对比度,确保文字清晰可读,同时保持与其他按钮在文字样式上的一致性。 */
}
.btn-success { /* 用于表示成功状态或正向操作的按钮(如 “保存成功”“提交完成”“确认通过” 等场景) */
background:#2ecc71; /* 按钮背景色为青绿色,这是国际通用的成功 / 正向操作色,视觉上给人积极、安全的感受,常用于标识操作成功或具有明确积极意义的交互(如 “确认”“完成” 等)。 */
color:#fff /* 文字为纯白色,与绿色背景形成高对比度,确保文字文字清晰可读,同时保持与其他按钮在文字样式上的一致性。 */
}
.empty-state, .loading { /* 这是一个群组选择器,同时对两个类应用相同的样式,避免代码重复,确保空状态和加载状态在基础视觉表现上保持一致。 */
text-align:center; /* 使内容(如提示文字、加载图标等)在容器中水平居中对齐,这种居中布局符合用户对状态提示的视觉预期,显得更规整。 */
padding:30px; /* 让状态提示区域与周围内容保持足够距离,形成独立的视觉区块,突出状态信息 */
color:#777 /* 文字颜色为中灰色,既保证可读性,又不会过于抢眼,适合作为辅助性的状态提示(不干扰页面核心内容) */
}
.empty-state i, .loading i { /* 群组选择器,同时作用于空状态和加载状态容器内的 <i> 标签(图标元素),确保两种状态下的图标样式统一。 */
font-size:2em; /* 将图标大小设置为 2em(相对单位),比普通文字大得多,使图标成为状态提示中的视觉焦点,通过醒目的图标快速传递状态信息(如空状态可能用文件夹或文件图标,加载状态可能用旋转图标) */
margin-bottom:10px /* 使其与下方的文字说明(如 “暂无数据”“加载中...”)保持距离,避免图标与文字紧贴,提升状态提示区域的布局清晰度 */
}
@keyframes spin { /* 声明一个名为 spin 的动画,后续可以通过 animation 属性将此动画应用到任意元素上 */
0% {/* 动画开始时(0% 进度),元素旋转角度为 0 度(初始状态) */
transform:rotate(0deg)
}
100% { /* 动画结束时(100% 进度),元素旋转角度为 360 度(完整旋转一周) */
transform:rotate(360deg)
}
}
.loading i { /* 选择器定位到类为 .loading 的容器内的 <i> 标签(通常是图标元素),专门针对加载状态的图标应用样式 */
animation:spin 1s linear infinite /* 这是动画属性的简写。 动画持续时间为 1 秒,即图标旋转一周需要 1 秒。 */
} /* 动画速度曲线为匀速,使旋转过程平滑无加速或减速。动画循环次数为无限,确保在加载状态持续期间,图标一直保持旋转 */
.toast { /* 定义了一个名为 .toast 的样式类,用于创建页面中的轻量级提示框(类似 “Toast 通知”),通常用于操作成功 / 失败、系统提示等场景 */
position:fixed; /* 采用固定定位,使提示框脱离文档流,位置相对于浏览器窗口固定,不会随页面滚动而移动,确保用户始终能看到提示。 */
top:10px; /* 定位在页面右上角,这是 Toast 通知的经典位置(不遮挡主要内容,又能快速被注意到) */
right:10px; /* ... */
padding:8px 12px; /* 内部上下内边距 8px、左右 12px,让提示文字与边框保持适当距离,避免拥挤。 */
border-radius:3px; /* 使提示框外观更柔和,符合现代 UI 设计风格。 */
color:#fff; /* 文字颜色为白色,通常会配合不同背景色(如成功用绿色、错误用红色)使用,保证文字清晰可读。 */
font-size:.85em; /* 文字大小略小于默认字体,作为辅助提示信息不抢占视觉焦点。 */
z-index:1000; /* 较高的层级值,确保提示框显示在其他元素上方,不会被页面内容遮挡。 */
transform:translateX(100%); /* 默认状态下,通过 X 轴平移(向右移动自身宽度的 100%)将提示框移出视口右侧,实现 “隐藏” 效果。 */
transition:transform .3s /* 为 transform 属性添加过渡动画,使提示框的显示 / 隐藏过程平滑无突兀感。 */
}
.toast.show { /* 这是一个多类选择器,仅作用于同时拥有 toast 和 show 两个类的元素。结合之前定义的 .toast 基础样式,这个类专门用于触发提示框的 “显示” 状态 */
transform:translateX(0) /* 将提示框的 X 轴偏移量设为 0,使其从右侧滑入并完全显示在视口内(右上角位置)由于设置了过渡时间,会以动画的形式完成 */
}
.toast.success { /* 它在 .toast 基础样式(定位、尺寸、动画等)的基础上,专门定制 “成功” 状态的视觉特征 */
background:#4caf50 /* 设置背景色为鲜亮的绿色,通过颜色直观传递 “操作成功” 的积极信息,与错误状态(通常用红色)、警告状态(通常用黄色)形成明确区分。 */
}
.toast.error { /* 它在 .toast 基础样式(固定定位、尺寸、圆角、文字颜色、过渡动画等)的基础上,专门定制 “错误” 状态的视觉特征 */
background:#e74c3c /* 这是经典的 “错误 / 危险色” */
}
.toast.info {
background:#2196f3 /* 这是经典的 “信息 / 通知色” */
}
.toast.warning {
background:#f39c12 /* 这是经典的 “警告 / 注意色” */
}
.modal {
display:none; /* 模态框默认处于隐藏状态,需要通过 JavaScript 动态修改为display: flex(或其他显示值)来触发显示。 */
position:fixed; /* 采用固定定位,使模态框脱离文档流,位置相对于浏览器窗口固定,不随页面滚动而移动,确保弹窗始终在视野内。 */
top:0;
left:0;
width:100%;
height:100%;
background:rgba(0,0,0,0.5); /* 设置背景为半透明黑色(透明度 50%),作为模态框的遮罩层,既模糊遮挡底层页面内容,又突出弹窗本身,增强视觉层次感。 */
z-index:999; /* 较高的层级值(999)确保模态框显示在页面其他元素之上,不会被底层内容遮挡(通常比普通元素层级高,但低于顶级提示如 Toast)。 */
justify-content:center; /* 让所有子元素在主轴方向上居中对齐 */
align-items:center; /* 让所有子元素在交叉轴方向上居中对齐。 */
}
.modal.show {
display:flex /* 将模态框的显示模式设置为 Flex 布局 */
}
.modal-content{ /* 模态框(Modal)内部的内容容器样式(.modal-content) */
background:#fff; /* 内容容器背景设为纯白色,与外部 .modal 的半透明黑色遮罩形成强烈对比,使弹窗内容从遮罩中凸显出来,提升可读性和视觉焦点 */
padding:15px; /* 让容器内的内容(如标题、文本、按钮)与边框保持适当距离,避免内容紧贴边缘导致的拥挤感,提升排版舒适度 */
border-radius:4px; /* 圆角设计,使内容容器的边角更柔和,与模态框的整体现代风格统一,避免直角带来的生硬感。 */
width:90%; /* 确保在手机等小屏幕设备上,弹窗不会超出屏幕边缘,保持良好的适配性。 */
max-width:500px /* 避免在桌面等大屏幕设备上弹窗过宽(过宽的内容会降低阅读体验),确保内容区域始终保持在舒适的阅读宽度范围内。 */
}
.modal-header { /* 用于组织模态框顶部区域的布局(通常包含标题和关闭按钮) */
display:flex; /* 使头部容器内的子元素(如标题、关闭按钮)可以灵活排列,便于实现两端对齐等布局效果 */
justify-content:space-between; /* 两端元素分别靠向容器的左右两端,中间自动留白。 */
margin-bottom:12px; /* 保持距离,避免视觉上的拥挤 */
padding-bottom:8px; /* 让头部内容(标题、按钮)与底部边框之间有一定空隙,提升内部排版的呼吸感 */
border-bottom:1px solid #eee /* 头部底部添加一条浅灰色的细边框,作为头部与内容区域的视觉分隔线,强化模态框内部的区域划分,让结构更清晰 */
}
.modal-title { /* 美化模态框头部的标题文字 */
font-size:1.1em; /* 既保证标题在视觉上的突出性(区别于正文内容),又不会因过大而显得突兀,符合 “标题略大于正文” 的排版逻辑 */
color:#555 /* 标题文字颜色为深灰色,既纯黑色更柔和,避免视觉刺眼,同时比浅灰色更清晰,确保标题的可读性。这种中性调既符合现代 UI 的简约风格,又能与模态框的白色背景形成适当对比,突出标题信息。 */
}
.modal-close { /* 定义了模态框关闭按钮的样式,用于美化和功能化模态框的关闭按钮(通常是 “×” 符号或 “关闭” 文字) */
background:none; /* 移除按钮默认的背景样式(如灰色背景或系统默认按钮背景),使按钮呈现 “透明背景” 效果,更贴合现代简约的 UI 设计,避免与模态框头部的白色背景产生视觉冲突。 */
border:none; /* 去除按钮默认的边框(如浏览器自带的按钮边框),进一步简化按钮外观,使其仅通过内容(如 “×” 符号)传递功能,不添加多余的视觉元素。 */
font-size:1.2em; /* 按钮文字 / 符号大小设置为 1.2em,比模态框标题略大,确保关闭按钮在视觉上足够显眼,用户能快速识别并点击。 */
cursor:pointer; /* 明确提示这是一个可点击的交互元素,增强用户操作的引导性。 */
color:#999 /* 按钮文字 / 符号颜色为浅灰色,既不会过于抢眼(避免分散对标题的注意力),又能清晰可见,符合其 “辅助功能按钮” 的定位 */
}
.modal-body { /* 用于包裹核心内容的容器类(如表单、文本、列表等主要信息) */
margin-bottom:15px /* 是与下方的元素保持距离,避免内容区域与操作区域紧贴在一起,形成清晰的视觉分隔,提升模态框内部布局的层次感和可读性。 */
}
.modal-footer { /* 用于布局模态框底部的操作按钮 */
display:flex; /* 使底部区域内的子元素(通常是按钮)能够灵活排列,便于控制按钮之间的间距和对齐方式 */
gap:8px; /* 确保多个按钮(如 “取消” 和 “确认”)之间有均匀的空隙,避免拥挤在一起,提升操作区域的整洁度和可读性。 */
justify-content:flex-end /* 控制 Flex 容器内子元素在主轴(默认水平方向)上的对齐方式:让所有子元素靠右对齐 */
}
.file-upload-area { /* 用于创建一个直观的 “拖放或点击上传文件” 的交互区域,常见于表单中的文件上传功能 */
border: 2px dashed #ccc; /* 虚线边框是文件上传区域的经典设计,通过视觉特征(非实线)暗示这是一个 “可交互的上传区域”,区别于普通容器。 */
padding: 20px; /* 为区域内的提示文字(如 “点击或拖放文件到此处”)、图标等内容预留足够空间,避免内容紧贴边框导致的拥挤感。 */
text-align: center; /* 区域内的文本和 inline 元素(如图标)水平居中对齐,确保提示信息在视觉上更规整,符合用户对 “上传区域” 的预期布局。 */
border-radius: 4px; /* 使边框边角更柔和,与整体 UI(如模态框的圆角风格)保持一致,避免直角带来的生硬感。 */
cursor: pointer; /* 鼠标悬停时显示 “手型光标”,明确提示该区域是可点击的交互元素,引导用户点击触发文件选择。 */
transition: border-color 0.3s; /* 为边框颜色变化添加 0.3 秒过渡动画,使状态变化(如鼠标悬停时边框变色)更平滑自然。通常会配合 :hover 伪类使用 */
margin-bottom: 10px; /* 使上传区域与下方元素(如文件预览区、提交按钮)保持距离,提升整体布局的层次感 */
}
.file-upload-area:hover { /* 示当鼠标指针悬停在元素上时,应用该样式。这是一种常见的交互反馈设计,通过视觉变化告知用户 “当前元素可交互”。 */
border-color: #888; /* 深灰色 */
}
.file-info { /* 主要用于显示文件上传相关的辅助信息(如文件名、大小、格式提示等) */
font-size: 0.9em; /* 比默认文字大小略小,明确其 “辅助信息” 的定位,避免与主要内容(如上传区域提示文字、按钮文本)争夺视觉焦点。 */
color: #666; /* 比正文文字颜色更柔和,既保证可读性,又通过颜色区分强调其 “补充说明” 的性质(如上传后的文件名、格式限制提示等)。 */
margin-top: 8px; /* 使文件信息区域与上方元素保持适当距离,避免信息堆叠,提升布局的呼吸感和清晰度。 */
}
.file-progress { /* 文件上传进度条容器的基础样式,用于展示文件上传过程中的进度状态 */
height: 5px; /* 用纤细的设计,既不占用过多视觉空间,又能清晰展示进度状态,符合常见进度条的紧凑设计风格 */
background-color: #eee; /* 作为进度条的 “底色”,当进度填充时,会被进度颜色覆盖,形成 “已完成进度” 与 “剩余进度” 的视觉对比。 */
border-radius: 3px; /* 与上传区域、模态框等组件的圆角风格统一,使界面元素风格协调,避免直角的生硬感。 */
overflow: hidden; /* 设置溢出隐藏,确保内部用于展示进度的子元素不会超出容器范围,保持进度条的整齐外观。 */
margin-top: 10px; /* 使进度条与上方元素保持距离,避免布局拥挤,提升信息层级的清晰度。 */
display: none; /* 默认隐藏进度条容器,因为进度条仅在文件上传过程中需要展示(未上传或上传完成后通常隐藏) */
}
.file-progress-bar { /* 定义了文件上传进度条的核心样式,用于直观展示文件上传的实时进度 */
height: 100%; /* 进度条高度设置为父容器的 100%,即填满父容器的 5px 高度,确保进度条在垂直方向上完全覆盖容器,形成完整的进度展示效果。 */
background-color: #4caf50; /* 与之前定义的 “成功状态”颜色一致,形成视觉风格统一,同时绿色传递 “正常进度”“成功” 的积极信号,让用户感知上传过程正常。 */
width: 0%; /* 初始宽度为 0%,表示上传未开始时进度条完全隐藏。实际使用时,会通过 JavaScript 根据上传进度动态修改该值(如从 0% 递增到 100%),直观反映文件上传的完成比例。 */
transition: width 0.3s; /* 为宽度变化添加 0.3 秒过渡动画,使进度条的宽度从一个值平滑过渡到另一个值(如从 30% 到 40%),避免进度跳变带来的突兀感,提升用户对进度变化的感知流畅度。 */
}
/* 文件元信息显示 */
.file-meta-info { /* 定义了文件元信息展示区域的样式,用于展示文件的详细元数据(如文件大小、类型、上传时间、修改日期等),通常在文件上传完成后显示 */
background: #e9f5e9; /* 低饱和度的 “成功色系”,与文件上传成功的状态形成视觉呼应,传递 “文件已处理完成” 的积极信号,同时浅色调不会过于抢眼。 */
padding: 8px; /* 为元信息内容(如文字、列表项)提供足够的呼吸空间,避免内容紧贴边框导致的拥挤感,提升可读性 */
border-radius: 3px; /* 与模态框、上传区域等组件的圆角风格统一,使界面元素风格协调,避免直角带来的生硬感。 */
border-left: 3px solid #2ecc71; /* 通过左侧的彩色色块色块与浅绿背景形成层次对比,突出元信息区域的边界; 深绿色与背景色同属绿色系,既协调又有区分度,强化 “成功 / 完成” 的状态暗示。 */
margin-top: 8px; /* 使元信息区域与上方元素(如进度条、文件信息提示)保持距离,避免信息堆叠,清晰划分不同功能区域(上传操作区→进度展示→元信息详情) */
font-size: 0.9em; /* 明确其 “辅助信息” 的定位,既保证内容可读,又不抢占主要操作区域的视觉焦点 */
}
.file-meta-info.error { /* 专门用于标识 “错误状态” 的文件元信息 */
background: #fdecea; /* 将背景色从基础样式的浅绿色改为浅红色。这种低饱和度的红色系背景,既与 “错误” 状态的视觉语言呼应,又不会过于刺眼,适合展示错误相关的辅助信息。 */
border-left-color: #e74c3c; /* 将左侧强调边框的颜色从基础样式的深绿色改为鲜红色。这个颜色与 .toast.error 中定义的错误状态主色完全一致,形成跨组件的视觉统一 —— 通过左侧醒目的红色边框,快速传递 “文件处理出错” 的状态,与成功状态的绿色边框形成鲜明对比,让用户一眼识别信息的性质。 */
}
.hidden-field { /* 定义了一个用于强制隐藏元素的类,主要用于需要彻底隐藏页面元素(如表单字段、临时内容等)的场景 */
display: none !important; /* 核心作用是将元素从页面布局中完全移除:元素不仅不可见,还不会占据任何空间(与 visibility: hidden 不同,后者仅隐藏元素但保留其占位空间)。
*/
} /* CSS 中的 “重要声明” 标记,用于强制提升该样式的优先级,确保它能覆盖其他可能影响该元素 display 属性的样式规则(即使其他规则的选择器权重更高)。 */
/* 下载按钮样式 */
.download-btn { /* 定义了下载按钮的样式,主要用于文件相关功能中(如下载已上传的文件),设计上突出了可交互性且保持与整体 UI 风格的一致性 */
margin-left: 8px; /* 使其与左侧相邻元素(如文件名、其他按钮)保持适当距离,避免元素紧贴,提升布局的呼吸感。 */
padding: 2px 6px; /* 小型按钮的紧凑设计,既保证按钮有足够的点击区域(符合交互体验),又不会占用过多空间,适合作为辅助性操作按钮(如在文件信息旁附加下载功能) */
font-size: 0.8em; /* 字体大小为 0.8em(小于默认文字大小),配合紧凑的内边距,强化 “小型辅助按钮” 的定位,避免与主要操作按钮(如 “确认”“上传”)争夺视觉焦点。 */
background-color: #2ecc71; /* 背景色采用深绿色,与之前定义的 “成功状态” 元素颜色一致,形成跨组件的视觉统一,同时绿色传递 “安全、可执行” 的积极信号,引导用户点击。 */
color: white; /* 确保按钮文字清晰可读,即使在小字号下也能轻松识别。 */
border-radius: 3px; /* 与模态框、文件元信息等组件的圆角风格统一,使按钮边角更柔和,避免直角带来的生硬感。 */
cursor: pointer; /* 鼠标悬停时显示 “手型光标”,明确提示这是可点击的交互元素,增强用户操作的引导性。 */
border: none; /* 移除按钮默认边框,使按钮外观更简洁,与绿色背景融为一体,符合现代 UI 的简约设计风格。 */
}
.download-btn:hover {
background-color: #27ae60; /* 深绿色 */
}
/* 数据修复提示 */
.data-repair-hint { /* 数据修复相关的提示信息样式,用于在数据修复过程中向用户展示提示、说明或状态信息(如修复进度、注意事项等) */
background: #fff3cd; /* 背景色为浅淡黄色,属于低饱和度的 “提示色系”。这种颜色既比白色背景更醒目(能吸引用户注意),又比红色(错误)、绿色(成功)更柔和,适合传递 “需要关注但非紧急 / 错误” 的信息,符合 “数据修复提示” 的功能定位 */
border-left: 3px solid #ffc107; /* 深黄色 */
padding: 8px; /* 为提示文字(如 “正在修复损坏的数据...”)预留空间,避免内容紧贴边框导致的拥挤感,提升可读性 */
margin: 10px 0; /* 使提示区域在垂直方向上与上下元素(如操作按钮、数据列表)保持距离,避免信息堆叠,清晰划分功能区域。 */
border-radius: 3px; /* 与模态框、文件元信息、按钮等组件的圆角风格统一,使界面元素风格协调,避免直角的生硬感。 */
font-size: 0.9em; /* 明确其 “辅助提示” 的定位,既保证内容可读,又不抢占主要操作区域的视觉焦点。 */
}
@media (max-width:600px){ /* 响应式媒体查询,用于在屏幕宽度小于或等于 600px(通常对应手机等移动设备)时,调整页面元素的样式,确保在小屏幕上有更好的显示效果和用户体验 */
.header{
flex-direction:column; /* 主轴方向设置为垂直方向(默认是水平方向)。这意味着头部内的子元素(如标题、导航项、操作按钮等)会从上到下依次排列,而非左右并排,适合需要垂直堆叠布局的头部设计(如移动端头部或紧凑布局的头部) */
gap:8px; /* 避免子元素紧贴在一起,让布局更透气、层次更清晰。 */
padding:10px /* 使头部内容(如文字、按钮)与容器边缘保持一定距离,避免内容贴边导致的拥挤感,同时提升视觉上的舒适感。 */
}
.header-actions{ /* 头部操作区的布局样式,主要用于控制头部区域中操作按钮 / 功能项的排列方式 */
width:100%; /* 使头部操作区的宽度占满其父容器的全部宽度,确保操作区在水平方向上充分利用可用空间,避免因宽度不足导致内部元素(如按钮、图标)挤压或换行 */
justify-content:center /* 该属性控制其内部子元素(如 “登录”“设置” 按钮、菜单图标等)在水平方向(主轴)上居中对齐 */
}
.form-actions{ /* 主要用于控制表单底部操作按钮(如 “提交”“取消”“重置” 等)的排列行为 */
flex-wrap:wrap /* 允许子元素(按钮)在容器宽度不足时自动换行。 */
}
.form-actions .btn{ /* 作用于所有包含在 .form-actions 容器内、类名为 btn 的按钮(即表单底部的 “提交”“取消” 等操作按钮) */
flex:1 /* 这是 Flex 布局的核心属性简写,等价于 flex: 1 1 0%,包含三个子属性 */
} /* flex-grow: 1:按钮可以根据父容器的剩余空间 “扩展”,扩展比例为 1(所有按钮的扩展比例相同) */
/* flex-shrink: 1:当父容器空间不足时,按钮可以 “收缩”,收缩比例为 1(所有按钮按相同比例收缩) */
/* flex-basis: 0%:按钮的初始宽度基准为 0,确保分配空间时完全基于 flex-grow 的比例,而非按钮自身内容的宽度 */
.modal-content{
width:95% /* 设置该容器的宽度为其父元素(通常是模态框的外层容器,如 .modal 或直接相对于 <body>)宽度的 95%。
这种百分比宽度的设计核心是响应式适配 */
}
textarea.form-control{
min-height:100px /* 最小高度为 100 像素 */
}
.move-mode-bar {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.move-mode-actions {
width: 100%;
justify-content: flex-start;
}
}
/* ===== 节点迁移功能样式 ===== */
.btn-move { /* 移动按钮:用于触发节点迁移模式 */
background: #3498db;
color: #fff;
}
.btn-move:hover {
background: #2980b9;
}
.move-mode-bar { /* 迁移模式提示栏:固定在页面顶部,显示正在迁移的节点信息和操作按钮 */
display: flex;
justify-content: space-between;
align-items: center;
background: #2c3e50;
color: #fff;
padding: 10px 15px;
gap: 10px;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.move-mode-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9em;
}
.move-mode-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn-move-here { /* 移动到此按钮:确认将节点移到当前位置 */
background: #2ecc71;
color: #fff;
padding: 6px 14px;
font-size: 0.9em;
border: none;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.btn-move-here:hover {
background: #27ae60;
}
.btn-cancel-move { /* 取消迁移按钮 */
background: transparent;
color: #ccc;
padding: 6px 10px;
font-size: 0.9em;
border: 1px solid #666;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.btn-cancel-move:hover {
background: rgba(255,255,255,0.1);
color: #fff;
}
File diff suppressed because it is too large. Load diff
-144
View File
@@ -1,144 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>树状笔记</title>
<!-- ​Font Awesome 6.0.0 是矢量图标库,提供 7,000+ 免费图标及 17,000+ 专业图标。 -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<!-- 外部CSS文件 -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-sitemap"></i> 树状笔记</h1>
<div class="header-actions">
<button onclick="resetForm()" class="btn btn-primary"><i class="fas fa-plus"></i> 新增节点</button>
<button onclick="goBack()" class="btn btn-secondary"><i class="fas fa-arrow-left"></i> 返回上级</button>
</div>
</div>
<!-- 节点迁移模式提示栏 -->
<div id="move-mode-bar" class="move-mode-bar" style="display: none;">
<div class="move-mode-info">
<i class="fas fa-exchange-alt"></i>
正在迁移节点:<strong id="moving-node-title"></strong>
</div>
<div class="move-mode-actions">
<button onclick="confirmMoveHere()" class="btn btn-move-here"><i class="fas fa-arrow-down"></i> 移动到此</button>
<button onclick="cancelMove()" class="btn btn-cancel-move"><i class="fas fa-times"></i> 取消</button>
</div>
</div>
<div class="main-content">
<div class="form-section">
<!-- 表单标题 -->
<h3 class="form-title"><i class="fas fa-pen"></i> <span id="form-mode-text">新增节点</span></h3>
<input type="hidden" id="edit-id" value=""> <!-- 隐藏输入框,要编辑节点的id -->
<!-- 输入节点标题 -->
<div class="form-group">
<label for="node-title"><i class="fas fa-heading"></i> 标题</label>
<input type="text" id="node-title" class="form-control" placeholder="输入节点标题">
</div>
<!-- 输入节点内容 -->
<div class="form-group">
<label for="node-content"><i class="fas fa-file-alt"></i> 节点说明</label>
<textarea id="node-content" class="form-control" placeholder="输入节点的文字说明"></textarea>
</div>
<!-- 文件上传区域 -->
<div class="form-group">
<label><i class="fas fa-upload"></i> 文件上传</label>
<div class="file-upload-area" onclick="document.getElementById('file-upload').click()">
<i class="fas fa-cloud-upload-alt" style="font-size: 2em; color: #888; margin-bottom: 10px;"></i>
<p>点击或拖拽文件到此处上传</p>
<p style="font-size: 0.8em; color: #999;">文件将以编码形式保存,同一节点内数据不要超过1M</p>
<input type="file" id="file-upload" style="display: none;" onchange="handleFileUpload(this.files)"> <!-- 隐藏输入框 -->
</div>
<!-- 选择文件状态信息 -->
<div class="file-info" id="file-info">未选择文件</div>
<!-- 文件元信息显示 -->
<div id="file-meta-container" style="display: none;">
<div id="file-meta-info" class="file-meta-info">
<p><i class="fas fa-file"></i> 文件名:<span id="meta-filename"></span></p>
<p><i class="fas fa-hdd"></i> 文件大小:<span id="meta-filesize"></span></p>
<p><i class="fas fa-calendar-alt"></i> 上传时间:<span id="meta-uploadtime"></span></p>
</div>
</div>
<!-- 数据修复提示 -->
<div id="data-repair-hint" class="data-repair-hint" style="display: none;">
<i class="fas fa-exclamation-circle"></i>
<span>检测到文件元数据损坏。请尝试重新上传文件以修复此问题。</span>
</div>
<!-- 文件进度条 -->
<div class="file-progress">
<div class="file-progress-bar" id="file-progress-bar"></div>
</div>
</div>
<!-- 文件下载区域(仅在有文件时显示) -->
<div class="form-group" id="download-section" style="display: none;">
<label><i class="fas fa-download"></i> 文件操作</label>
<div style="display: flex; gap: 8px;">
<button onclick="decodeAndDownload(null, null, true)" class="btn btn-success">
<i class="fas fa-download"></i> 下载文件
</button>
<button onclick="removeFile()" class="btn btn-danger">
<i class="fas fa-trash"></i> 移除文件
</button>
</div>
</div>
<!-- 隐藏的Base64存储字段 - 用户永远不会看到 -->
<textarea id="hidden-base64" class="form-control hidden-field" placeholder="Base64编码内容(自动生成,隐藏显示)"></textarea>
<input type="hidden" id="file-metadata" value="">
<!-- 动作表单 -->
<div class="form-actions">
<button onclick="saveNode()" class="btn btn-primary"><i class="fas fa-save"></i> 保存</button>
<button onclick="resetForm()" class="btn btn-secondary"><i class="fas fa-undo"></i> 重置</button>
</div>
</div>
<!-- 节点表单 -->
<div id="node-list" class="node-list">
<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>
</div>
</div>
</div>
<!-- 删除确认模态框 -->
<div id="delete-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title"><i class="fas fa-exclamation-triangle"></i> 确认删除</h3>
<button class="modal-close" onclick="closeDeleteModal()"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<p id="delete-message">确定删除该节点吗?</p>
<p id="delete-node-title-container" style="font-weight: bold; margin: 8px 0;"></p>
<p id="delete-file-hint" style="font-size:.85em;color:#e74c3c;display:none;"><i class="fas fa-exclamation-circle"></i> 此节点包含文件,删除后文件将无法恢复</p>
<p style="font-size:.85em;color:#777">此操作不可恢复</p>
</div>
<div class="modal-footer">
<button onclick="confirmDelete()" class="btn btn-danger"><i class="fas fa-trash"></i> 删除</button>
<button onclick="closeDeleteModal()" class="btn btn-secondary"><i class="fas fa-times"></i> 取消</button>
</div>
</div>
</div>
<div id="toast-container"></div>
<!-- 外部JS文件 -->
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <us/HttpClient.hpp>
#include "Msg.hpp"
#include <boost/json.hpp>
namespace qing {
namespace json = boost::json;
class DbC {
public:
// 【保留】旧版 HTTP 接口:通过 HttpClient POST 到 dbv2 HTTP /execute 端点。
// 保留用于兼容性参考,新代码优先使用 call_msg()(pymsg TCP 协议,更可靠)。
static json::value call_http(json::value& params) {
auto client = pmc::net::HttpClient();
client.set_header("Content-Type: application/json");
std::string payload = json::serialize(params);
std::string res = "";
client.post_stream_sync(_DBURL, payload, [&res](const auto& ret) {
res += ret;
});
return json::parse(res);
}
// 新版 pymsg 接口:通过 MsgClient(TCP 长度前缀帧协议)调用 dbv2。
// 自动添加 _route = "execute",返回 dbv2 pymsg 完整 JSON 响应
// {"success":..., "data":[...]}。
// 响应格式与 call_http 兼容,调用方可直接替换。
static json::value call_msg(const json::value& params,
double timeout = 5.0) {
json::value jv_route = params;
jv_route.as_object()["_route"] = "execute";
pmc::net::MsgClient client("127.0.0.1", 8005, "gateway");
std::string resp = client.call(
json::serialize(jv_route), timeout);
return json::parse(resp);
}
private:
static constexpr const char* _DBURL = "http://127.0.0.1:8005/execute";
};
}
+334
View File
@@ -0,0 +1,334 @@
#pragma once
/** pymsg C++ 实现 — Length-prefixed variable-length socket message framing.
*
* Protocol: [4-byte big-endian uint32_t length][UTF-8 payload]
*
* 对应 Python 版 pymsg/pymsg/__init__.py,提供:
* - pmsg_send() / pmsg_recv() 底层协议收发函数
* - MsgClient 短连接 send / call / 长连接 listen 客户端
*
* Usage:
* #include "Msg.hpp"
*
* pmc::net::MsgClient client("127.0.0.1", 8005, "gateway");
* std::string resp = client.call(R"({"sql":"SELECT 1"})", 5.0);
* client.send("hello");
*/
#include <cerrno>
#include <cstdint>
#include <cstring>
#include <functional>
#include <stdexcept>
#include <string>
#include <vector>
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <sys/types.h>
#include <unistd.h>
// macOS 没有 MSG_NOSIGNAL,用 0 替代(内核不会发 SIGPIPE)
#ifndef MSG_NOSIGNAL
#define MSG_NOSIGNAL 0
#endif
namespace pmc {
namespace net {
// ============================================================================
// Exceptions
// ============================================================================
class MsgException : public std::runtime_error {
public:
explicit MsgException(const std::string &msg) : std::runtime_error(msg) {}
};
class MsgTimeoutException : public MsgException {
public:
explicit MsgTimeoutException(const std::string &msg) : MsgException(msg) {}
};
class MsgConnectionException : public MsgException {
public:
explicit MsgConnectionException(const std::string &msg) : MsgException(msg) {}
};
// ============================================================================
// Low-level protocol: 4-byte big-endian length prefix + UTF-8 payload
// ============================================================================
namespace detail {
constexpr size_t kHeaderSize = 4;
// ---- 4-byte big-endian 编码 / 解码 ----------------------------------------
inline void writeUint32BE(char *buf, uint32_t val) noexcept {
buf[0] = static_cast<char>((val >> 24) & 0xFFu);
buf[1] = static_cast<char>((val >> 16) & 0xFFu);
buf[2] = static_cast<char>((val >> 8) & 0xFFu);
buf[3] = static_cast<char>(val & 0xFFu);
}
inline uint32_t readUint32BE(const char *buf) noexcept {
return (static_cast<uint32_t>(static_cast<unsigned char>(buf[0])) << 24) |
(static_cast<uint32_t>(static_cast<unsigned char>(buf[1])) << 16) |
(static_cast<uint32_t>(static_cast<unsigned char>(buf[2])) << 8) |
(static_cast<uint32_t>(static_cast<unsigned char>(buf[3])));
}
// ---- socket 超时设置 ------------------------------------------------------
inline void setSockTimeout(int sockfd, double seconds) {
struct timeval tv {};
tv.tv_sec = static_cast<time_t>(seconds);
tv.tv_usec =
static_cast<long>((seconds - static_cast<double>(tv.tv_sec)) * 1'000'000.0);
::setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
::setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
}
// ---- 精确读取 n 字节 ------------------------------------------------------
inline void recvExact(int sockfd, char *buf, size_t n) {
size_t received = 0;
while (received < n) {
ssize_t r = ::recv(sockfd, buf + received, n - received, 0);
if (r == 0) {
throw MsgConnectionException(
"connection closed: expected " + std::to_string(n) +
" bytes, got " + std::to_string(received));
}
if (r < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
throw MsgTimeoutException("recv timed out");
}
throw MsgConnectionException(
"recv error: " + std::string(::strerror(errno)));
}
received += static_cast<size_t>(r);
}
}
// ---- 完整发送 -------------------------------------------------------------
inline void sendAll(int sockfd, const char *data, size_t len) {
size_t sent = 0;
while (sent < len) {
ssize_t w =
::send(sockfd, data + sent, len - sent, MSG_NOSIGNAL);
if (w < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
throw MsgTimeoutException("send timed out");
}
throw MsgConnectionException(
"send error: " + std::string(::strerror(errno)));
}
sent += static_cast<size_t>(w);
}
}
// ---- 短连接 helper:connect → action → close ------------------------------
inline int openConnection(const std::string& addr, int port, double timeout) {
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
throw MsgConnectionException(
"socket() failed: " + std::string(::strerror(errno)));
}
struct sockaddr_in serv_addr {};
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(static_cast<uint16_t>(port));
if (::inet_pton(AF_INET, addr.c_str(), &serv_addr.sin_addr) <= 0) {
::close(fd);
throw MsgConnectionException("invalid address: " + addr);
}
setSockTimeout(fd, timeout);
if (::connect(fd, reinterpret_cast<struct sockaddr *>(&serv_addr),
sizeof(serv_addr)) < 0) {
::close(fd);
throw MsgConnectionException(
"could not connect to " + addr + ":" + std::to_string(port) +
" - " + ::strerror(errno));
}
return fd;
}
} // namespace detail
// ============================================================================
// 底层协议函数:pmsg_send / pmsg_recv
// ============================================================================
/// 以 pymsg 协议发送一条字符串消息(长度前缀 + UTF-8 载荷)。
inline void pmsg_send(int sockfd, const std::string &data) {
size_t payload_size = data.size();
if (payload_size > 0xFFFFFFFFu) {
throw MsgException("payload too large: " + std::to_string(payload_size));
}
char header[detail::kHeaderSize];
detail::writeUint32BE(header, static_cast<uint32_t>(payload_size));
detail::sendAll(sockfd, header, detail::kHeaderSize);
detail::sendAll(sockfd, data.c_str(), payload_size);
}
/// 以 pymsg 协议接收一条字符串消息。
/// @throws MsgConnectionException 连接在收全消息前断开。
/// @throws MsgTimeoutException 等待超时。
inline std::string pmsg_recv(int sockfd) {
char header[detail::kHeaderSize];
detail::recvExact(sockfd, header, detail::kHeaderSize);
uint32_t length = detail::readUint32BE(header);
if (length > 64 * 1024 * 1024) { // 安全上限 64 MiB
throw MsgException("message too large: " + std::to_string(length));
}
std::string payload(static_cast<size_t>(length), '\0');
detail::recvExact(sockfd, payload.data(), length);
return payload;
}
// ============================================================================
// MsgClient
// ============================================================================
/// 基于 pymsg 协议的 TCP 客户端。
///
/// 支持三种操作模式:
/// - send() 短连接发送一条消息(不等待回复),消息带 name 前缀。
/// - call() 短连接发送 + 阻塞等待回复(RPC 模式),原始消息不添加前缀。
/// - listen() 基于 connect() 维持的长连接,持续接收消息并回调。
class MsgClient {
public:
MsgClient(const std::string &addr, int port, const std::string &name)
: _addr(addr), _port(port), _name(name), _sockfd(-1), _running(false) {}
~MsgClient() { stop(); }
// ---- 连接 ---------------------------------------------------------------
/// 创建 TCP 套接字并连接到服务器(长连接,供 listen() 使用)。
void connect() {
_sockfd = detail::openConnection(_addr, _port, /*timeout=*/10.0);
}
// ---- 发送单条消息(短连接,带名字前缀)----------------------------------
/// 短连接发送一条消息到服务器,消息格式为 ``"name: msg"``。
/// 不等待回复。短连接(每次创建新 socket,发完即关)。
void send(const std::string &msg) {
int fd = detail::openConnection(_addr, _port, /*timeout=*/3.0);
try {
pmsg_send(fd, _name + ": " + msg);
::close(fd);
} catch (...) {
::close(fd);
throw;
}
}
// ---- 请求-应答(短连接,RPC 模式)---------------------------------------
/// 短连接发送一条消息,阻塞等待并返回一条回复。
///
/// 不会给消息添加 ``name:`` 前缀(适用于 RPC/API 调用)。
///
/// @param msg 要发送的原始字符串。
/// @param timeout 超时秒数,默认 10.0。
/// @returns 服务器返回的字符串。
std::string call(const std::string &msg, double timeout = 10.0) {
int fd = detail::openConnection(_addr, _port, timeout);
try {
pmsg_send(fd, msg);
std::string resp = pmsg_recv(fd);
::close(fd);
return resp;
} catch (...) {
::close(fd);
throw;
}
}
// ---- 持续监听(长连接,阻塞回调模式)-----------------------------------
/// 在长连接上持续接收消息并通过回调处理。
///
/// 阻塞当前线程直到 stop() 被调用或连接断开。
/// 必须先调用 connect()。
///
/// @param on_message 回调 ``void(const std::string &msg)``。
void listen(std::function<void(const std::string &)> on_message) {
if (_sockfd < 0) {
throw MsgException("must call connect() before listen()");
}
_running = true;
while (_running) {
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(_sockfd, &readfds);
struct timeval tv {};
tv.tv_sec = 0;
tv.tv_usec = 100'000; // 100 ms 轮询间隔
int ready =
::select(_sockfd + 1, &readfds, nullptr, nullptr, &tv);
if (ready < 0) {
if (errno == EINTR) continue;
break; // EBADF or other fatal error
}
if (ready == 0) continue; // timeout, loop around
try {
std::string text = pmsg_recv(_sockfd);
on_message(std::move(text));
} catch (const MsgConnectionException &) {
break; // 连接断开,退出循环
} catch (const MsgTimeoutException &) {
continue; // 读超时但连接仍在,继续等待
}
}
if (_sockfd >= 0) {
::close(_sockfd);
_sockfd = -1;
}
}
// ---- 停止 ---------------------------------------------------------------
/// 设置停止标志并关闭套接字。可从任意线程调用。
void stop() noexcept {
_running = false;
if (_sockfd >= 0) {
::close(_sockfd);
_sockfd = -1;
}
}
// ---- 访问器 -------------------------------------------------------------
const std::string &addr() const noexcept { return _addr; }
int port() const noexcept { return _port; }
const std::string &name() const noexcept { return _name; }
private:
std::string _addr;
int _port;
std::string _name;
int _sockfd;
bool _running;
};
} // namespace net
} // namespace pmc
+424 -437
View File
File diff suppressed because it is too large. Load diff
+112
View File
@@ -0,0 +1,112 @@
#prjs
*.db
*.bin
*.build
*.dist
*.onefile-build
nuitka-crash-report.xml
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
+370
View File
@@ -0,0 +1,370 @@
"""pymsg - Length-prefixed variable-length socket message framing.
Protocol: [4-byte big-endian uint32 length][UTF-8 payload]
Usage:
import socket
from pymsg import send, recv
# sender
send(sock, "hello world")
# receiver
msg = recv(sock)
# with timeout (seconds)
send(sock, "hello", timeout=5.0)
msg = recv(sock, timeout=5.0)
"""
import contextlib
import select
import socket as _socket
import struct
__all__ = ["send", "recv", "TimeoutError", "MsgClient", "MsgServer"]
_HEADER = struct.Struct("!I") # 4 bytes, big-endian unsigned int
class TimeoutError(Exception):
"""Raised when a send or recv operation exceeds its timeout."""
@contextlib.contextmanager
def _timeout(sock: "_socket.socket", seconds: "float | None"):
"""Temporarily set the socket timeout, restoring it on exit."""
if seconds is None:
yield
return
original = sock.gettimeout()
sock.settimeout(seconds)
try:
yield
finally:
sock.settimeout(original)
def send(
sock: "_socket.socket",
data: str,
*,
timeout: "float | None" = None,
) -> None:
"""Send a string over a socket using length-prefixed framing.
Args:
sock: A connected socket object.
data: The string message to send.
timeout: Optional timeout in seconds. Raises TimeoutError on expiry.
"""
payload = data.encode("utf-8")
header = _HEADER.pack(len(payload))
with _timeout(sock, timeout):
try:
sock.sendall(header + payload)
except _socket.timeout:
raise TimeoutError(
f"send timed out after {timeout}s"
) from None
def recv(
sock: "_socket.socket",
*,
timeout: "float | None" = None,
) -> str:
"""Receive a length-prefixed string message from a socket.
Args:
sock: A connected socket object.
timeout: Optional timeout in seconds. Raises TimeoutError on expiry.
Returns:
The decoded string message.
Raises:
ConnectionError: If the connection is closed before a complete
message is received.
TimeoutError: If the operation exceeds the timeout.
"""
with _timeout(sock, timeout):
try:
header = _recv_exact(sock, _HEADER.size)
(length,) = _HEADER.unpack(header)
payload = _recv_exact(sock, length)
return payload.decode("utf-8")
except _socket.timeout:
raise TimeoutError(
f"recv timed out after {timeout}s"
) from None
def _recv_exact(sock: "_socket.socket", n: int) -> bytes:
"""Read exactly n bytes from sock, raising on premature close."""
buf = bytearray()
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError(
f"connection closed: expected {n} bytes, got {len(buf)}"
)
buf.extend(chunk)
return bytes(buf)
# ---------------------------------------------------------------------------
# 高层类:封装基于 pymsg 的客户端 / 服务器
# ---------------------------------------------------------------------------
class MsgClient:
"""TCP 聊天客户端:封装套接字创建、连接、发送、监听与关闭。
``listen()`` 在调用线程内阻塞运行,可另开线程调用 ``stop()`` 来终止。
Usage::
def on_msg(text: str) -> None:
print(f"收到: {text}")
c = MsgClient("localhost", 8003, "Alice")
c.connect()
c.send("hello") # 发送单条消息
c.listen(on_msg) # 阻塞当前线程,持续回调
"""
def __init__(self, addr: str, port: int, name: str) -> None:
self.addr = addr
self.port = port
self.name = name
self._sock: "_socket.socket | None" = None
self._running = False
# ---- 连接 ------------------------------------------------------------
def connect(self) -> None:
"""创建 TCP 套接字并连接到服务器。"""
self._sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
self._sock.connect((self.addr, self.port))
# ---- 发送单条消息(短连接,不等待回复)--------------------------------
def send(self, msg: str) -> None:
"""发送一条消息到服务器(使用短连接,pymsg 协议)。"""
try:
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s:
s.settimeout(3)
s.connect((self.addr, self.port))
send(s, f"{self.name}: {msg}")
except ConnectionRefusedError:
raise ConnectionRefusedError(
f"Could not connect to {self.addr}:{self.port}"
)
# ---- 请求-应答(短连接)------------------------------------------------
def call(self, msg: str, *, timeout: "float | None" = None) -> str:
"""发送一条消息并阻塞等待单条回复(短连接:connect → send → recv → close)。
适合请求-应答模式,如 RPC 调用。不会给消息添加 name 前缀。
Args:
msg: 要发送的原始字符串。
timeout: 超时秒数,默认 10 秒。
Returns:
str: 服务器返回的字符串。
Raises:
ConnectionRefusedError: 目标服务不可达。
TimeoutError: 等待响应超时。
ConnectionError: 连接在收全数据前断开。
"""
_tout = timeout if timeout is not None else 10.0
try:
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s:
s.settimeout(_tout)
s.connect((self.addr, self.port))
send(s, msg)
return recv(s, timeout=_tout)
except ConnectionRefusedError:
raise ConnectionRefusedError(
f"Could not connect to {self.addr}:{self.port}"
)
# ---- 持续监听(阻塞当前线程,回调模式)-------------------------------
def listen(self, on_message: "callable") -> None:
"""阻塞当前线程,持续接收服务器消息并回调。
Args:
on_message: 回调 ``on_message(text: str) -> None``。
"""
if self._sock is None:
raise RuntimeError("必须先调用 connect()")
self._running = True
socklist = [self._sock]
while self._running:
try:
readable, _, exception = select.select(
socklist, [], socklist, 0.1
)
if readable:
try:
text = recv(self._sock)
on_message(text)
except ConnectionError:
break
if exception:
break
except (ConnectionResetError, OSError):
break
# ---- 关闭 ------------------------------------------------------------
def stop(self) -> None:
"""设置停止标志并断开 TCP 连接。
可在另一个线程中调用,使 ``listen()`` 退出阻塞。
"""
self._running = False
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
class MsgServer:
"""基于 pymsg 长度前缀协议的多客户端 TCP 服务器。
收到消息时调用回调函数 —— 转发、过滤等逻辑全部在回调中实现。
Usage::
def on_msg(sender_sock, addr, text):
# 转发给所有人(排除发送者)
server.broadcast(f"[{addr}] {text}", except_sock=sender_sock)
server = MsgServer("0.0.0.0", 8003)
server.start(on_msg) # 阻塞当前线程
# 在另一线程中调用 server.stop() 可优雅退出
"""
def __init__(self, addr: str, port: int) -> None:
self.addr = addr
self.port = port
self._running = False
self._server_sock: "_socket.socket | None" = None
self._clients: "dict" = {} # socket -> (ip, port)
# ---- 启动(阻塞)----------------------------------------------------
def start(self, on_message: "callable") -> None:
"""启动服务器,阻塞当前线程。
Args:
on_message: 回调 ``on_message(sock, addr, text)``
- sock: 发送消息的客户端套接字
- addr: (ip, port) 元组
- text: 解码后的字符串消息
"""
self._running = True
servsock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
servsock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
servsock.bind((self.addr, self.port))
servsock.listen(10)
self._server_sock = servsock
socketlist = [servsock]
while self._running:
try:
readable, _, exception = select.select(
socketlist, [], socketlist, 0.1
)
except (ValueError, OSError):
break
for s in exception:
self._remove_client(s, socketlist)
for s in readable:
if s is servsock:
self._accept_client(servsock, socketlist)
else:
self._handle_client(s, socketlist, on_message)
self._shutdown(servsock, socketlist)
# ---- 广播 -----------------------------------------------------------
def broadcast(
self, msg: str, except_sock: "_socket.socket | None" = None
) -> None:
"""向所有已连接客户端广播消息(pymsg 协议)。
Args:
msg: 要发送的字符串。
except_sock: 排除的套接字(通常为发送者),不向其发送。
"""
disconnected = []
for client in list(self._clients):
if client is except_sock:
continue
try:
send(client, msg)
except (BrokenPipeError, ConnectionResetError, OSError):
disconnected.append(client)
for client in disconnected:
self._clients.pop(client, None)
try:
client.close()
except OSError:
pass
# ---- 应答 -----------------------------------------------------------
def reply(self, sock: "_socket.socket", msg: str) -> None:
"""向指定客户端套接字发送一条应答消息(pymsg 协议)。
用于请求-应答模式:服务器收到消息后,通过同一 socket 回复处理结果。
"""
send(sock, msg)
# ---- 停止 -----------------------------------------------------------
def stop(self) -> None:
"""设置停止标志并关闭监听套接字(可从另一线程调用)。"""
self._running = False
if self._server_sock is not None:
try:
self._server_sock.close()
except OSError:
pass
# ---- 内部方法 -------------------------------------------------------
def _accept_client(self, servsock, socketlist):
try:
client_sock, client_addr = servsock.accept()
if client_sock.fileno() != -1:
socketlist.append(client_sock)
self._clients[client_sock] = client_addr
except OSError:
pass
def _handle_client(self, s, socketlist, on_message):
try:
text = recv(s)
on_message(s, self._clients.get(s, ("unknown", 0)), text)
except (ConnectionError, ConnectionResetError, OSError):
self._remove_client(s, socketlist)
def _remove_client(self, s, socketlist):
self._clients.pop(s, None)
if s in socketlist:
socketlist.remove(s)
try:
s.close()
except OSError:
pass
def _shutdown(self, servsock, socketlist):
for s in socketlist:
try:
s.close()
except OSError:
pass
self._clients.clear()
+13
View File
@@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "pymsg"
version = "0.1.0"
description = "Length-prefixed variable-length socket message framing"
requires-python = ">=3.9"
license = "MIT"
[project.urls]
Repository = "https://github.com/user/micro_servers"
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
.env
venv/
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
source ./venv/bin/activate
#exec ./smtp.bin
exec python3 smtp.py
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
python3 -m venv venv
#sudo chmod +x .env/bin/activate
source venv/bin/activate
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
#nuitka --standalone smtp.py
+182
View File
@@ -0,0 +1,182 @@
"""
统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
设计要点:
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
用法:
from log_client import logs
logs.info("smtp", "验证码发送成功")
logs.error("smtp", "验证码发送失败", extra={"errno": 2002})
logs.push("smtp", "WARNING", "登录失败次数超限",
trace_id="req-abc", extra={"ip": "1.2.3.4"})
"""
import json
import os as _os
import sys as _sys
import threading
import queue
import time
import atexit
from datetime import datetime, timezone
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
from pymsg import MsgClient # noqa: E402
# ── 配置 ──────────────────────────────────────────────────────────────
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
_BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
_TIMEOUT = 5 # socket 超时(秒)
class LogClient:
"""异步批量日志推送客户端。
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
"""
def __init__(self, service_name="smtp",
batch_size=_BATCH_SIZE,
flush_interval=_FLUSH_INTERVAL):
self._service_name = service_name
self._batch_size = batch_size
self._flush_interval = flush_interval
self._queue = queue.Queue()
self._stop = threading.Event()
self._flush_signal = threading.Event()
self._thread = threading.Thread(target=self._flush_loop,
daemon=True, name='log-client')
self._thread.start()
atexit.register(self._shutdown)
# ── 公开接口 ────────────────────────────────────────────────────────
def push(self, service, level, message, trace_id=None, extra=None):
"""推送一条日志(非阻塞,立即返回)。"""
self._queue.put({
'service': service,
'level': level,
'message': message,
'trace_id': trace_id,
'extra': extra,
})
def debug(self, service, message, **kw):
self.push(service, 'DEBUG', message, **kw)
def info(self, service, message, **kw):
self.push(service, 'INFO', message, **kw)
def warning(self, service, message, **kw):
self.push(service, 'WARNING', message, **kw)
def error(self, service, message, **kw):
self.push(service, 'ERROR', message, **kw)
def critical(self, service, message, **kw):
self.push(service, 'CRITICAL', message, **kw)
def flush(self):
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
# 等待后台线程响应(queue.get 超时最长 0.5s)
time.sleep(0.6)
# 排空主队列中尚未被线程取走的条目
batch = []
while True:
try:
batch.append(self._queue.get_nowait())
if len(batch) >= self._batch_size:
self._send(batch)
batch = []
except queue.Empty:
break
if batch:
self._send(batch)
self._flush_signal.clear()
# ── 内部实现 ────────────────────────────────────────────────────────
def _flush_loop(self):
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。"""
batch = []
last_flush = time.monotonic()
while not self._stop.is_set():
timeout = self._flush_interval - (time.monotonic() - last_flush)
# 限制最长阻塞时间,以便及时响应 flush() 信号
chk_timeout = max(0.1, min(timeout, 0.5))
try:
item = self._queue.get(timeout=chk_timeout)
batch.append(item)
while len(batch) < self._batch_size:
try:
batch.append(self._queue.get_nowait())
except queue.Empty:
break
except queue.Empty:
pass
should_flush = (
len(batch) >= self._batch_size or
self._flush_signal.is_set() or
(batch and time.monotonic() - last_flush >= self._flush_interval)
)
if should_flush and batch:
self._send(batch)
batch = []
last_flush = time.monotonic()
# 退出前 flush 剩余
if batch:
self._send(batch)
def _send(self, batch):
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
now = datetime.now(timezone.utc).isoformat()
try:
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
for entry in batch:
extra_val = entry.get("extra")
payload = {
"_route": "execute",
"action": "insert",
"table": "Logs",
"values": {
"ts": now,
"service": entry.get("service"),
"level": entry.get("level"),
"message": entry.get("message"),
"trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False)
if extra_val is not None else None,
},
}
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
resp_obj = json.loads(resp)
if not resp_obj.get("success"):
import sys
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
file=sys.stderr)
except Exception as e:
import sys
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
def _shutdown(self):
"""进程退出时 flush 剩余日志。"""
self._stop.set()
self.flush()
# ── 全局单例 ──────────────────────────────────────────────────────────
logs = LogClient(service_name="smtp")
+8
View File
@@ -0,0 +1,8 @@
../pymsg
nuitka
flask
flask-cors
flask_sqlalchemy
#gunicorn
waitress
python-dotenv
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
from flask import Flask, request, jsonify
from email.mime.text import MIMEText
from email import header
from log_client import logs
from dotenv import load_dotenv
import waitress
import smtplib
import random
import time
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
app = Flask(__name__)
captchas: dict[str, list[str, str]] = {}
@app.route("/send_email", methods=["POST"])
def send_email():
def build_captcha() -> str:
captcha: str = ""
for i in range(0,6):
digit: int = random.randint(0,9)
captcha += str(digit)
return captcha
data = request.get_json()
recipient: str = data.get("email")
subject: str = "DNFS 邮箱验证"
captcha: str = build_captcha()
content: str = f"DNFS: 您的邮箱验证码为 {captcha},一分钟内有效。"
captchas[recipient] = ["", ""]
captchas[recipient][0] = captcha
captchas[recipient][1] = str(int(time.time())+60)
message = MIMEText(content, "plain", "UTF-8")
message["From"] = "dnfs@qsont.me"
message["To"] = recipient
message["Subject"] = subject
try:
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
server.login(SMTP_USER, __PASSWORD)
server.sendmail(SMTP_USER, [recipient], message.as_string())
logs.info("smtp", f"{recipient}: 验证码发送成功({captcha})")
return jsonify({"success": "验证码发送成功"}), 200
except Exception as err:
logs.error("smtp", f"{recipient}: 验证码发送失败({err})")
return jsonify({"error": f"验证码发送失败:{err}"}), 400
finally:
server.quit()
@app.route("/verify_email", methods=["POST"])
def verify_email():
data = request.get_json()
for i in list(captchas.keys()):
if int(captchas[i][1]) < int(time.time()):
del captchas[i]
email: str = data.get("email")
input_captcha: str = data.get("input_captcha")
if captchas[email][0] == input_captcha:
logs.info("smtp", f"{email}: 验证通过")
return jsonify({"success": "验证通过"}), 200
else:
logs.info("smtp", f"{email}: 验证失败")
return jsonify({"error": "验证失败"}), 400
if __name__ == "__main__":
logs.info("smtp", "开始监听http://127.0.0.1:8008")
print("Listen at http://127.0.0.1:8008")
waitress.serve(app, host="127.0.0.1", port=8008)
+576 -303
View File
@@ -1,303 +1,576 @@
# file_server.py - 极简版文件托管服务器
from flask import Flask, send_from_directory, jsonify, request, Response, send_file
from flask_cors import CORS
import os
import argparse
import datetime
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
import configparser
import sys
from security_middleware import apply_security_headers
# 基准目录:编译后取可执行文件所在目录,开发时取脚本所在目录
# 解决 Nuitka onefile 解压到 /tmp 后相对路径失效的问题
if getattr(sys, 'frozen', False):
BASE_DIR = os.path.dirname(os.path.abspath(sys.executable))
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
file_handler = RotatingFileHandler(os.path.join(BASE_DIR, 'app.log'), maxBytes=10485760, backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s'
))
file_handler.setLevel(logging.INFO)
logger.addHandler(file_handler)
parser = argparse.ArgumentParser(description='File Server')
parser.add_argument('--config', type=str, default='config.ini', help='Configuration file path')
args = parser.parse_args()
app = Flask(__name__)
apply_security_headers(app)
CORS(app)
# ==================== 配置文件加载 ====================
CONFIG_FILE = args.config
if not os.path.isabs(CONFIG_FILE):
CONFIG_FILE = os.path.join(BASE_DIR, CONFIG_FILE)
file_config = {
'server': {'host': '0.0.0.0', 'port': '5000'},
'directory': {'root': './public'},
'ssl': {'enabled': 'false', 'cert_file': 'cert.pem', 'key_file': 'key.pem'},
}
if os.path.exists(CONFIG_FILE):
config_parser = configparser.ConfigParser()
config_parser.read(CONFIG_FILE, encoding='utf-8')
for section in ['server', 'directory', 'ssl']:
if section in config_parser:
for key, value in config_parser[section].items():
if key in file_config[section]:
file_config[section][key] = value
HTML_ROOT_DIR = file_config['directory']['root']
if HTML_ROOT_DIR and not os.path.isabs(HTML_ROOT_DIR):
HTML_ROOT_DIR = os.path.join(BASE_DIR, HTML_ROOT_DIR)
FILESYSTEM_ENABLED = bool(HTML_ROOT_DIR and HTML_ROOT_DIR.strip())
if FILESYSTEM_ENABLED:
try:
Path(HTML_ROOT_DIR).mkdir(parents=True, exist_ok=True)
logger.info(f"Created directory: {HTML_ROOT_DIR}")
except Exception as e:
logger.error(f"Failed to create directory: {str(e)}")
FILESYSTEM_ENABLED = False
SSL_ENABLED = file_config['ssl'].get('enabled', 'false').lower() == 'true'
SSL_CERT_FILE = file_config['ssl'].get('cert_file', 'cert.pem')
SSL_KEY_FILE = file_config['ssl'].get('key_file', 'key.pem')
if not os.path.isabs(SSL_CERT_FILE):
SSL_CERT_FILE = os.path.join(BASE_DIR, SSL_CERT_FILE)
if not os.path.isabs(SSL_KEY_FILE):
SSL_KEY_FILE = os.path.join(BASE_DIR, SSL_KEY_FILE)
# 视图文件名:根路径与目录访问时固定返回此静态文件,由前端 JS 调用 /api/filelist 拉取数据
VIEW_FILE = '__view.html'
# ==================== 视频文件支持 ====================
VIDEO_MIME_TYPES = {
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
'.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska'
}
AUDIO_MIME_TYPES = {
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
'.flac': 'audio/flac', '.aac': 'audio/aac', '.m4a': 'audio/mp4'
}
def is_video_file(filename):
return os.path.splitext(filename)[1].lower() in VIDEO_MIME_TYPES
def is_audio_file(filename):
return os.path.splitext(filename)[1].lower() in AUDIO_MIME_TYPES
def get_media_mime_type(filename):
ext = os.path.splitext(filename)[1].lower()
return VIDEO_MIME_TYPES.get(ext) or AUDIO_MIME_TYPES.get(ext) or 'application/octet-stream'
def safe_path_join(base_dir, user_path):
if not user_path or user_path.strip() == '' or user_path.strip() == '/':
return base_dir
if user_path.startswith('/'):
user_path = user_path[1:]
full_path = os.path.normpath(os.path.join(base_dir, user_path))
base_dir_real = os.path.realpath(base_dir)
full_path_real = os.path.realpath(full_path)
if not full_path_real.startswith(base_dir_real):
return None
return full_path_real
def format_file_size(size):
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024.0:
return f"{size:.1f}{unit}"
size /= 1024.0
return f"{size:.1f}TB"
# ==================== API 接口 ====================
@app.route('/api/filelist', methods=['GET'])
def get_file_list():
try:
if not FILESYSTEM_ENABLED:
return jsonify({'error': '文件系统未启用'}), 403
requested_path = request.args.get('path', '')
full_path = safe_path_join(HTML_ROOT_DIR, requested_path)
if full_path is None:
return jsonify({'error': '无效的路径'}), 403
if not os.path.exists(full_path) or not os.path.isdir(full_path):
return jsonify({'error': '目录不存在'}), 404
items = []
for item in os.listdir(full_path):
# 过滤系统项目:以双下划线开头的文件和文件夹(如 __pycache__、__view.html、__icons 等)
if item.startswith('__'):
continue
item_path = os.path.join(full_path, item)
item_stat = os.stat(item_path)
is_dir = os.path.isdir(item_path)
item_rel_path = os.path.join(requested_path, item) if requested_path else item
file_type = 'dir' if is_dir else ('video' if is_video_file(item) else ('audio' if is_audio_file(item) else 'file'))
items.append({
'name': item,
'type': file_type,
'path': item_rel_path,
'size': item_stat.st_size if not is_dir else 0,
'size_formatted': format_file_size(item_stat.st_size) if not is_dir else '',
'modified': datetime.datetime.fromtimestamp(item_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
})
items.sort(key=lambda x: (x['type'] != 'dir', x['name'].lower()))
return jsonify({
'success': True,
'path': requested_path or '/',
'parent_path': os.path.dirname(requested_path) if requested_path and os.path.dirname(requested_path) != '/' else '',
'items': items
})
except Exception as e:
logger.error(f"Get file list error: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/video/<path:file_path>', methods=['GET'])
def stream_video(file_path):
try:
if not FILESYSTEM_ENABLED:
return jsonify({'error': '文件系统未启用'}), 403
full_path = safe_path_join(HTML_ROOT_DIR, file_path)
if full_path is None or not os.path.exists(full_path) or not os.path.isfile(full_path):
return jsonify({'error': '文件不存在'}), 404
if not is_video_file(full_path) and not is_audio_file(full_path):
return jsonify({'error': '不支持的文件类型'}), 400
file_size = os.path.getsize(full_path)
mime_type = get_media_mime_type(full_path)
range_header = request.headers.get('Range', None)
if range_header:
byte_range = range_header.replace('bytes=', '').split('-')
start = int(byte_range[0]) if byte_range[0] else 0
end = int(byte_range[1]) if len(byte_range) > 1 and byte_range[1] else file_size - 1
if start >= file_size:
return jsonify({'error': '请求范围超出文件大小'}), 416
length = end - start + 1
with open(full_path, 'rb') as f:
f.seek(start)
data = f.read(length)
response = Response(data, 206, mimetype=mime_type, direct_passthrough=True)
response.headers.add('Content-Range', f'bytes {start}-{end}/{file_size}')
response.headers.add('Accept-Ranges', 'bytes')
response.headers.add('Content-Length', str(length))
response.headers.add('Cache-Control', 'no-cache')
return response
else:
return send_file(full_path, mimetype=mime_type, as_attachment=False)
except Exception as e:
logger.error(f"Video streaming error: {str(e)}")
return jsonify({'error': str(e)}), 500
# ==================== 主路由 ====================
@app.route('/')
def serve_view():
"""根路径:优先返回 index.html(社区首页),不存在时回退到 __view.html(文件浏览)"""
if not FILESYSTEM_ENABLED:
return "文件系统未启用", 403
index_path = os.path.join(HTML_ROOT_DIR, 'index.html')
if os.path.exists(index_path):
return send_file(index_path, mimetype='text/html')
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
if not os.path.exists(view_path):
return f"视图文件 {VIEW_FILE} 不存在", 500
return send_file(view_path, mimetype='text/html')
@app.route('/<path:path>')
def serve_file(path):
try:
if not FILESYSTEM_ENABLED:
return "文件系统未启用", 403
full_path = safe_path_join(HTML_ROOT_DIR, path)
if full_path is None:
return "无效的路径", 403
if not os.path.exists(full_path):
return "路径不存在", 404
# 文件处理
if os.path.isfile(full_path):
if request.args.get('download') == 'true':
return send_file(full_path, as_attachment=True, download_name=os.path.basename(full_path))
if is_video_file(full_path) or is_audio_file(full_path):
return send_file(full_path, mimetype=get_media_mime_type(full_path))
# 普通文件
with open(full_path, 'rb') as f:
content = f.read()
ext = os.path.splitext(full_path)[1].lower()
mime_map = {
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css',
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.txt': 'text/plain',
'.pdf': 'application/pdf', '.svg': 'image/svg+xml', '.ico': 'image/x-icon'
}
return Response(content, mimetype=mime_map.get(ext, 'application/octet-stream'))
# 目录处理:返回 __view.html,由前端 JS 根据 URL 路径调用 /api/filelist 拉取文件列表
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
if os.path.exists(view_path):
return send_file(view_path, mimetype='text/html')
return "目录无法浏览", 404
except Exception as e:
logger.error(f"Error: {str(e)}")
import traceback
traceback.print_exc()
return f"服务器内部错误: {str(e)}", 500
@app.route('/health', methods=['GET'])
def health_check():
return jsonify({
'status': 'healthy',
'filesystem': {'enabled': FILESYSTEM_ENABLED, 'root': HTML_ROOT_DIR if FILESYSTEM_ENABLED else None}
})
# ==================== 主程序 ====================
if __name__ == '__main__':
host = file_config['server']['host']
port = int(file_config['server']['port'])
print("\n" + "="*50)
print("📁 文件托管服务器 (极简版)")
print("="*50)
print(f"地址: http://{host}:{port}")
print(f"目录: {os.path.abspath(HTML_ROOT_DIR) if FILESYSTEM_ENABLED else '未启用'}")
print("="*50)
print("\n快捷键: / 或 Ctrl+F 搜索, ESC 清空搜索")
print("\n")
if SSL_ENABLED and os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE):
app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False)
else:
app.run(host=host, port=port, debug=False)
# file_server.py - 极简版文件托管服务器
from flask import Flask, send_from_directory, jsonify, request, Response, send_file
from flask_cors import CORS
import os
import argparse
import datetime
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
import configparser
import sys
# pymsg 日志客户端(集中式日志 → dbv2 Logs 表)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from log_client import logs # noqa: E402
# 基准目录:编译后取可执行文件所在目录,开发时取脚本所在目录
# 解决 Nuitka onefile 解压到 /tmp 后相对路径失效的问题
if getattr(sys, 'frozen', False):
BASE_DIR = os.path.dirname(os.path.abspath(sys.executable))
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
file_handler = RotatingFileHandler(os.path.join(BASE_DIR, 'app.log'), maxBytes=10485760, backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s'
))
file_handler.setLevel(logging.INFO)
logger.addHandler(file_handler)
parser = argparse.ArgumentParser(description='File Server')
parser.add_argument('--config', type=str, default='config.ini', help='Configuration file path')
args = parser.parse_args()
app = Flask(__name__)
CORS(app, origins=["https://qsont.me", "https://yhdet.top", "http://localhost:9200"])
# ==================== 配置文件加载 ====================
CONFIG_FILE = args.config
if not os.path.isabs(CONFIG_FILE):
CONFIG_FILE = os.path.join(BASE_DIR, CONFIG_FILE)
file_config = {
'server': {'host': '0.0.0.0', 'port': '5000'},
'directory': {'root': './public'},
'ssl': {'enabled': 'false', 'cert_file': 'cert.pem', 'key_file': 'key.pem'},
}
if os.path.exists(CONFIG_FILE):
config_parser = configparser.ConfigParser()
config_parser.read(CONFIG_FILE, encoding='utf-8')
for section in ['server', 'directory', 'ssl']:
if section in config_parser:
for key, value in config_parser[section].items():
if key in file_config[section]:
file_config[section][key] = value
HTML_ROOT_DIR = file_config['directory']['root']
if HTML_ROOT_DIR and not os.path.isabs(HTML_ROOT_DIR):
HTML_ROOT_DIR = os.path.join(BASE_DIR, HTML_ROOT_DIR)
FILESYSTEM_ENABLED = bool(HTML_ROOT_DIR and HTML_ROOT_DIR.strip())
if FILESYSTEM_ENABLED:
try:
Path(HTML_ROOT_DIR).mkdir(parents=True, exist_ok=True)
logger.info(f"Created directory: {HTML_ROOT_DIR}")
logs.info("static", f"目录已创建: {HTML_ROOT_DIR}")
except Exception as e:
logger.error(f"Failed to create directory: {str(e)}")
logs.error("static", f"目录创建失败: {str(e)}",
extra={"path": HTML_ROOT_DIR})
FILESYSTEM_ENABLED = False
SSL_ENABLED = file_config['ssl'].get('enabled', 'false').lower() == 'true'
SSL_CERT_FILE = file_config['ssl'].get('cert_file', 'cert.pem')
SSL_KEY_FILE = file_config['ssl'].get('key_file', 'key.pem')
if not os.path.isabs(SSL_CERT_FILE):
SSL_CERT_FILE = os.path.join(BASE_DIR, SSL_CERT_FILE)
if not os.path.isabs(SSL_KEY_FILE):
SSL_KEY_FILE = os.path.join(BASE_DIR, SSL_KEY_FILE)
# 视图文件名:根路径与目录访问时固定返回此静态文件,由前端 JS 调用 /api/filelist 拉取数据
VIEW_FILE = '__view.html'
# ==================== 视频文件支持 ====================
VIDEO_MIME_TYPES = {
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
'.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska'
}
AUDIO_MIME_TYPES = {
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
'.flac': 'audio/flac', '.aac': 'audio/aac', '.m4a': 'audio/mp4'
}
def is_video_file(filename):
return os.path.splitext(filename)[1].lower() in VIDEO_MIME_TYPES
def is_audio_file(filename):
return os.path.splitext(filename)[1].lower() in AUDIO_MIME_TYPES
def get_media_mime_type(filename):
ext = os.path.splitext(filename)[1].lower()
return VIDEO_MIME_TYPES.get(ext) or AUDIO_MIME_TYPES.get(ext) or 'application/octet-stream'
def safe_path_join(base_dir, user_path):
if not user_path or user_path.strip() == '' or user_path.strip() == '/':
return base_dir
if user_path.startswith('/'):
user_path = user_path[1:]
full_path = os.path.normpath(os.path.join(base_dir, user_path))
base_dir_real = os.path.realpath(base_dir)
full_path_real = os.path.realpath(full_path)
if not full_path_real.startswith(base_dir_real):
return None
return full_path_real
def format_file_size(size):
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024.0:
return f"{size:.1f}{unit}"
size /= 1024.0
return f"{size:.1f}TB"
# ==================== API 接口 ====================
@app.route('/api/filelist', methods=['GET'])
def get_file_list():
try:
if not FILESYSTEM_ENABLED:
return jsonify({'error': '文件系统未启用'}), 403
requested_path = request.args.get('path', '')
full_path = safe_path_join(HTML_ROOT_DIR, requested_path)
if full_path is None:
return jsonify({'error': '无效的路径'}), 403
if not os.path.exists(full_path) or not os.path.isdir(full_path):
return jsonify({'error': '目录不存在'}), 404
items = []
for item in os.listdir(full_path):
# 过滤系统项目:以双下划线开头的文件和文件夹(如 __pycache__、__view.html、__icons 等)
if item.startswith('__'):
continue
item_path = os.path.join(full_path, item)
item_stat = os.stat(item_path)
is_dir = os.path.isdir(item_path)
item_rel_path = os.path.join(requested_path, item) if requested_path else item
file_type = 'dir' if is_dir else ('video' if is_video_file(item) else ('audio' if is_audio_file(item) else 'file'))
items.append({
'name': item,
'type': file_type,
'path': item_rel_path,
'size': item_stat.st_size if not is_dir else 0,
'size_formatted': format_file_size(item_stat.st_size) if not is_dir else '',
'modified': datetime.datetime.fromtimestamp(item_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
})
items.sort(key=lambda x: (x['type'] != 'dir', x['name'].lower()))
return jsonify({
'success': True,
'path': requested_path or '/',
'parent_path': os.path.dirname(requested_path) if requested_path and os.path.dirname(requested_path) != '/' else '',
'items': items
})
except Exception as e:
logger.error(f"Get file list error: {str(e)}")
logs.error("static", f"获取文件列表失败: {str(e)}",
extra={"path": requested_path})
return jsonify({'error': str(e)}), 500
@app.route('/video/<path:file_path>', methods=['GET'])
def stream_video(file_path):
try:
if not FILESYSTEM_ENABLED:
return jsonify({'error': '文件系统未启用'}), 403
full_path = safe_path_join(HTML_ROOT_DIR, file_path)
if full_path is None or not os.path.exists(full_path) or not os.path.isfile(full_path):
return jsonify({'error': '文件不存在'}), 404
if not is_video_file(full_path) and not is_audio_file(full_path):
return jsonify({'error': '不支持的文件类型'}), 400
file_size = os.path.getsize(full_path)
mime_type = get_media_mime_type(full_path)
range_header = request.headers.get('Range', None)
if range_header:
byte_range = range_header.replace('bytes=', '').split('-')
start = int(byte_range[0]) if byte_range[0] else 0
end = int(byte_range[1]) if len(byte_range) > 1 and byte_range[1] else file_size - 1
if start >= file_size:
return jsonify({'error': '请求范围超出文件大小'}), 416
length = end - start + 1
with open(full_path, 'rb') as f:
f.seek(start)
data = f.read(length)
response = Response(data, 206, mimetype=mime_type, direct_passthrough=True)
response.headers.add('Content-Range', f'bytes {start}-{end}/{file_size}')
response.headers.add('Accept-Ranges', 'bytes')
response.headers.add('Content-Length', str(length))
response.headers.add('Cache-Control', 'no-cache')
return response
else:
return send_file(full_path, mimetype=mime_type, as_attachment=False)
except Exception as e:
logger.error(f"Video streaming error: {str(e)}")
logs.error("static", f"视频流传输失败: {str(e)}",
extra={"file": file_path})
return jsonify({'error': str(e)}), 500
GATEWAY_DB = '/opt/micro_servers/gateway/sqlite3.db'
@app.route('/api/hash', methods=['POST'])
def api_hash():
from flask import request, jsonify
import hashlib
data = request.get_json(force=True)
h = hashlib.sha256(data.get('password','').encode()).hexdigest()
return jsonify({"hash": h})
@app.route('/api/reset_password', methods=['POST'])
def api_reset_password():
from flask import request, jsonify
import sqlite3 as _sql
data = request.get_json(force=True)
email = data.get('email', '').strip()
new_hash = data.get('password_hash', '').strip()
if not email or not new_hash:
return jsonify({"success": False, "error": "missing params"}), 400
conn = _sql.connect(GATEWAY_DB)
conn.execute("UPDATE Users SET password_hash=? WHERE email=?", (new_hash, email))
conn.commit()
conn.close()
logs.info("static", "密码重置", extra={"email": email[:3] + "***"})
return jsonify({"success": True})
@app.route('/api/check_user', methods=['POST'])
def api_check_user():
from flask import request, jsonify
import sqlite3 as _sql
data = request.get_json(force=True)
email = data.get('email', '').strip()
if not email:
return jsonify({"success": False, "error": "missing email"}), 400
conn = _sql.connect(GATEWAY_DB)
rows = conn.execute("SELECT email FROM Users WHERE email=?", (email,)).fetchall()
conn.close()
return jsonify({"success": True, "found": len(rows) > 0})
YHDET_VERIFY_URL = "https://yhdet.top/api/verify_auth_token"
YHDET_SECRET = "yhdet_qsont_oauth_2026_secret_key"
GATEWAY_DB_YHDET = '/opt/micro_servers/gateway/sqlite3.db'
def _yhdet_verify_token(token):
import traceback
import urllib.request, json
try:
data = json.dumps({"token": token}).encode()
req = urllib.request.Request(YHDET_VERIFY_URL, data=data,
headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
traceback.print_exc()
raise
@app.route('/yhdet_callback', methods=['GET', 'POST'])
def yhdet_callback():
from flask import request, make_response
import hashlib, sqlite3, base64, json as _json, urllib.request as _urlreq
token = (request.args.get('yhdet_token', '') or request.form.get('yhdet_token', '')).strip()
if not token:
return make_response("No token", 400)
try:
result = _yhdet_verify_token(token)
if not result.get("success"):
return make_response("Verify failed: " + str(result), 401)
username = result.get("username", "")
user_id = result.get("user_id", 0)
except Exception as e:
import traceback
traceback.print_exc()
return make_response("Verify error: " + str(e), 500)
email = f"yhdet_{user_id}@yhdet.oauth"
password = f"oauth_{user_id}"
conn = sqlite3.connect(GATEWAY_DB_YHDET)
rows = conn.execute("SELECT email FROM Users WHERE email=?", (email,)).fetchall()
if not rows:
fake_hash = hashlib.sha256(password.encode()).hexdigest()
conn.execute("INSERT INTO Users (email, password_hash) VALUES (?, ?)", (email, fake_hash))
conn.commit()
logs.info("static", "OAuth 新用户注册", extra={"email": email})
conn.close()
try:
login_data = _json.dumps({"username": email, "password": password}).encode()
req = _urlreq.Request("https://qsont.me/login", data=login_data,
headers={"Content-Type": "application/json"}, method="POST")
with _urlreq.urlopen(req, timeout=10, context=__import__('ssl')._create_unverified_context()) as resp:
login_result = _json.loads(resp.read().decode("utf-8"))
real_token = login_result.get("data", {}).get("token", "")
except Exception as e:
import traceback
traceback.print_exc()
real_token = ""
html = "<!DOCTYPE html><html><head><meta charset='utf-8'></head><body><p>Login...</p><script>"
if real_token:
html += "localStorage.setItem('q4_token','" + real_token + "');"
html += "sessionStorage.setItem('q4_token','" + real_token + "');"
html += "location.href='/dnfs.html';"
html += "</script></body></html>"
return make_response(html, 200)
@app.route('/api/yhdet_login', methods=['POST'])
def yhdet_login():
import hashlib, sqlite3, base64
data = request.get_json(force=True) if request.is_json else {}
email = data.get("email", "").strip()
password = data.get("password", "").strip()
if not email or not password:
return jsonify({"success": False, "error": "Missing params"}), 400
pwd_hash = hashlib.sha256(password.encode()).hexdigest()
conn = sqlite3.connect(GATEWAY_DB_YHDET)
rows = conn.execute("SELECT email, password_hash FROM Users WHERE email=?", (email,)).fetchall()
conn.close()
if not rows:
logs.warning("static", "yhdet 登录失败-用户不存在",
extra={"email": email[:8] + "***"})
return jsonify({"success": False, "error": "Invalid credentials"})
if rows[0][1] != pwd_hash:
logs.warning("static", "yhdet 登录失败-密码错误",
extra={"email": email[:8] + "***"})
return jsonify({"success": False, "error": "Invalid credentials"})
logs.info("static", "yhdet 登录成功",
extra={"email": email[:8] + "***"})
token_data = f"{email}:{password}"
token = base64.urlsafe_b64encode(token_data.encode()).decode()
return jsonify({"success": True, "token": token})
@app.route('/api/yhdet_verify', methods=['POST'])
def yhdet_verify():
import base64, hashlib, sqlite3
data = request.get_json(force=True)
token = data.get("token", "")
if not token:
return jsonify({"success": False}), 400
try:
decoded = base64.urlsafe_b64decode(token.encode()).decode()
parts = decoded.split(":")
if len(parts) == 3 and parts[1] == "oauth":
return jsonify({"success": True, "email": parts[0], "type": "oauth"})
if len(parts) == 2:
email, password = parts
pwd_hash = hashlib.sha256(password.encode()).hexdigest()
conn = sqlite3.connect(GATEWAY_DB_YHDET)
rows = conn.execute("SELECT email FROM Users WHERE email=? AND password_hash=?", (email, pwd_hash)).fetchall()
conn.close()
if rows:
return jsonify({"success": True, "email": email, "type": "password"})
return jsonify({"success": False})
except:
return jsonify({"success": False})
PRIVATE_SECRET = "qsont_me_yhdet_2026_private"
YHDET_PRIVATE_API = "https://yhdet.top/api/v1/private"
from flask import make_response
import urllib.request as _urlreq, json as _json
DNFS_URL = "http://127.0.0.1:8006"
def _cors(resp):
resp.headers["Access-Control-Allow-Origin"] = "*"
resp.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
return resp
@app.route('/get_notes', methods=['POST', 'OPTIONS'])
def proxy_get_notes():
if request.method == "OPTIONS": return _cors(make_response("", 204))
try:
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
req = _urlreq.Request(f"{DNFS_URL}/get_notes", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
except Exception as e:
logs.warning("static", "dnfs get_notes 代理失败",
extra={"error": str(e)})
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
@app.route('/add_note', methods=['POST', 'OPTIONS'])
def proxy_add_note():
if request.method == "OPTIONS": return _cors(make_response("", 204))
try:
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
req = _urlreq.Request(f"{DNFS_URL}/add_note", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
except Exception as e:
logs.warning("static", "dnfs add_note 代理失败", extra={"error": str(e)})
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
@app.route('/delete_note', methods=['POST', 'OPTIONS'])
def proxy_delete_note():
if request.method == "OPTIONS": return _cors(make_response("", 204))
try:
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
req = _urlreq.Request(f"{DNFS_URL}/delete_note", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
except Exception as e:
logs.warning("static", "dnfs delete_note 代理失败", extra={"error": str(e)})
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
@app.route('/update_note', methods=['POST', 'OPTIONS'])
def proxy_update_note():
if request.method == "OPTIONS": return _cors(make_response("", 204))
try:
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
req = _urlreq.Request(f"{DNFS_URL}/update_note", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
except Exception as e:
logs.warning("static", "dnfs update_note 代理失败",
extra={"error": str(e)})
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
@app.route('/api/proxy_send_code', methods=['POST'])
def proxy_send_code():
from flask import request, jsonify
import urllib.request as _urlreq, json as _json, __import__ as _imp
data = request.get_json(force=True)
req_data = _json.dumps(data).encode()
req = _urlreq.Request(f"{YHDET_PRIVATE_API}/send_code", data=req_data,
headers={"Content-Type": "application/json", "X-Forwarded-For": "8.137.52.11", "X-Secret": PRIVATE_SECRET}, method="POST")
ctx = _imp('ssl').create_default_context()
ctx.check_hostname = False
ctx.verify_mode = _imp('ssl').CERT_NONE
with _urlreq.urlopen(req, timeout=15, context=ctx) as resp:
return jsonify(_json.loads(resp.read().decode("utf-8")))
req = _urlreq.Request(f"{YHDET_PRIVATE_API}/check_code", data=req_data,
headers={"Content-Type": "application/json", "X-Forwarded-For": "8.137.52.11", "X-Secret": PRIVATE_SECRET}, method="POST")
ctx = _imp('ssl').create_default_context()
ctx.check_hostname = False
ctx.verify_mode = _imp('ssl').CERT_NONE
with _urlreq.urlopen(req, timeout=15, context=ctx) as resp:
return jsonify(_json.loads(resp.read().decode("utf-8")))
# ==================== 主路由 ====================
@app.route('/')
def serve_view():
"""根路径:优先返回 index.html(社区首页),不存在时回退到 __view.html(文件浏览)"""
if not FILESYSTEM_ENABLED:
return "文件系统未启用", 403
#index_path = os.path.join(HTML_ROOT_DIR, 'index.html')
index_path = os.path.join(HTML_ROOT_DIR, 'login.html')
if os.path.exists(index_path):
return send_file(index_path, mimetype='text/html')
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
if not os.path.exists(view_path):
return f"视图文件 {VIEW_FILE} 不存在", 500
return send_file(view_path, mimetype='text/html')
@app.route('/<path:path>')
def serve_file(path):
try:
if not FILESYSTEM_ENABLED:
return "文件系统未启用", 403
full_path = safe_path_join(HTML_ROOT_DIR, path)
if full_path is None:
return "无效的路径", 403
if not os.path.exists(full_path):
return "路径不存在", 404
# 文件处理
if os.path.isfile(full_path):
if request.args.get('download') == 'true':
return send_file(full_path, as_attachment=True, download_name=os.path.basename(full_path))
if is_video_file(full_path) or is_audio_file(full_path):
return send_file(full_path, mimetype=get_media_mime_type(full_path))
# 普通文件
with open(full_path, 'rb') as f:
content = f.read()
ext = os.path.splitext(full_path)[1].lower()
mime_map = {
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css',
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.txt': 'text/plain',
'.pdf': 'application/pdf', '.svg': 'image/svg+xml', '.ico': 'image/x-icon'
}
return Response(content, mimetype=mime_map.get(ext, 'application/octet-stream'))
# 目录处理:返回 __view.html,由前端 JS 根据 URL 路径调用 /api/filelist 拉取文件列表
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
if os.path.exists(view_path):
return send_file(view_path, mimetype='text/html')
return "目录无法浏览", 404
except Exception as e:
logger.error(f"Error: {str(e)}")
logs.error("static", f"静态文件服务错误: {str(e)}",
extra={"path": path})
import traceback
traceback.print_exc()
return f"服务器内部错误: {str(e)}", 500
@app.route('/health', methods=['GET'])
def health_check():
return jsonify({"status": "ok"})
if __name__ == '__main__':
host = file_config['server']['host']
port = int(file_config['server']['port'])
print("\n" + "="*50)
print("📁 文件托管服务器 (极简版)")
print("="*50)
print(f"地址: http://{host}:{port}")
print(f"目录: {os.path.abspath(HTML_ROOT_DIR) if FILESYSTEM_ENABLED else '未启用'}")
print("="*50)
print("\n快捷键: / 或 Ctrl+F 搜索, ESC 清空搜索")
print("\n")
if SSL_ENABLED and os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE):
app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False)
else:
app.run(host=host, port=port, debug=False)
+177
View File
@@ -0,0 +1,177 @@
"""
统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
设计要点:
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
用法:
from log_client import logs
logs.info("static", "服务启动成功")
logs.error("static", "文件系统未启用", extra={"path": "/data"})
"""
import json
import os as _os
import sys as _sys
import threading
import queue
import time
import atexit
from datetime import datetime, timezone
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
from pymsg import MsgClient # noqa: E402
# ── 配置 ──────────────────────────────────────────────────────────────
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
_BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
_TIMEOUT = 5 # socket 超时(秒)
class LogClient:
"""异步批量日志推送客户端。
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
"""
def __init__(self, service_name="static",
batch_size=_BATCH_SIZE,
flush_interval=_FLUSH_INTERVAL):
self._service_name = service_name
self._batch_size = batch_size
self._flush_interval = flush_interval
self._queue = queue.Queue()
self._stop = threading.Event()
self._flush_signal = threading.Event()
self._thread = threading.Thread(target=self._flush_loop,
daemon=True, name='log-client')
self._thread.start()
atexit.register(self._shutdown)
# ── 公开接口 ────────────────────────────────────────────────────────
def push(self, service, level, message, trace_id=None, extra=None):
"""推送一条日志(非阻塞,立即返回)。"""
self._queue.put({
'service': service,
'level': level,
'message': message,
'trace_id': trace_id,
'extra': extra,
})
def debug(self, service, message, **kw):
self.push(service, 'DEBUG', message, **kw)
def info(self, service, message, **kw):
self.push(service, 'INFO', message, **kw)
def warning(self, service, message, **kw):
self.push(service, 'WARNING', message, **kw)
def error(self, service, message, **kw):
self.push(service, 'ERROR', message, **kw)
def critical(self, service, message, **kw):
self.push(service, 'CRITICAL', message, **kw)
def flush(self):
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
time.sleep(0.6) # 等待后台线程响应
batch = []
while True:
try:
batch.append(self._queue.get_nowait())
if len(batch) >= self._batch_size:
self._send(batch)
batch = []
except queue.Empty:
break
if batch:
self._send(batch)
self._flush_signal.clear()
# ── 内部实现 ────────────────────────────────────────────────────────
def _flush_loop(self):
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。"""
batch = []
last_flush = time.monotonic()
while not self._stop.is_set():
timeout = self._flush_interval - (time.monotonic() - last_flush)
chk_timeout = max(0.1, min(timeout, 0.5))
try:
item = self._queue.get(timeout=chk_timeout)
batch.append(item)
while len(batch) < self._batch_size:
try:
batch.append(self._queue.get_nowait())
except queue.Empty:
break
except queue.Empty:
pass
should_flush = (
len(batch) >= self._batch_size or
self._flush_signal.is_set() or
(batch and time.monotonic() - last_flush >= self._flush_interval)
)
if should_flush and batch:
self._send(batch)
batch = []
last_flush = time.monotonic()
# 退出前 flush 剩余
if batch:
self._send(batch)
def _send(self, batch):
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
now = datetime.now(timezone.utc).isoformat()
try:
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
for entry in batch:
extra_val = entry.get("extra")
payload = {
"_route": "execute",
"action": "insert",
"table": "Logs",
"values": {
"ts": now,
"service": entry.get("service"),
"level": entry.get("level"),
"message": entry.get("message"),
"trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False)
if extra_val is not None else None,
},
}
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
resp_obj = json.loads(resp)
if not resp_obj.get("success"):
import sys
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
file=sys.stderr)
except Exception as e:
import sys
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
def _shutdown(self):
"""进程退出时 flush 剩余日志。"""
self._stop.set()
self.flush()
# ── 全局单例 ──────────────────────────────────────────────────────────
logs = LogClient(service_name="static")
+79
View File
@@ -217,6 +217,85 @@ button[type="button"], input[type="button"] { background: var(--accent-soft); co
.recommend-list .card h3 { transition: color 0.15s; }
.recommend-list .card p { font-size: 0.9rem; line-height: 1.6; color: var(--fg-secondary); }
/* ===== Minimap (dnfs) ===== */
.minimap {
position: fixed;
right: 1rem;
top: 50%;
transform: translateY(-50%);
z-index: 100;
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 80vh;
overflow-y: auto;
padding: 0.6rem 0.75rem;
border-radius: var(--radius-md);
background: rgba(253, 246, 238, 0.72);
border: 1px solid var(--border-light);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: var(--shadow-md);
transition: background 0.2s, border-color 0.2s;
}
@media (prefers-color-scheme: dark) {
.minimap {
background: rgba(30, 23, 18, 0.72);
border-color: var(--border);
}
}
.minimap:empty { display: none; }
.minimap .tag {
display: inline-block;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.25rem 0.6rem;
font-size: 0.78rem;
font-weight: 500;
color: var(--fg-secondary);
background: rgba(232, 133, 58, 0.08);
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
white-space: nowrap;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.minimap::-webkit-scrollbar { width: 4px; }
.minimap::-webkit-scrollbar-track { background: transparent; }
.minimap::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
.minimap::-webkit-scrollbar-thumb:hover { background: var(--muted); }
.minimap .tag:hover {
color: var(--accent-dark);
background: var(--accent-soft);
border-color: var(--accent);
}
.minimap .tag.back {
color: var(--accent);
background: var(--accent-soft);
border-color: var(--accent);
margin-bottom: 0.25rem;
}
.minimap .tag.fold-btn {
color: var(--fg-secondary);
background: transparent;
border: 1px solid var(--border);
font-weight: 600;
margin-bottom: 0.3rem;
text-align: center;
max-width: none;
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.minimap .tag.fold-btn:hover {
color: var(--accent-dark);
background: var(--accent-soft);
border-color: var(--accent);
}
/* ===== Node Form (dnfs) ===== */
.node-form { margin-top: 1rem; }
.node-form .form-actions { margin-top: 0.75rem; }
+1
View File
@@ -9,6 +9,7 @@
</head>
<body>
<div class="box">
<div id="minimap" class="minimap"></div>
<div class="recommend-list" id="recommend-list"></div>
<div class="card node-form">
<h3>节点操作</h3>
+86
View File
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<title>Forgot Password</title>
<link rel="stylesheet" href="css/theme.css">
<style>
.auth-box{max-width:400px;margin:60px auto;padding:30px;background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.06)}
.auth-box h2{text-align:center;margin-bottom:24px;color:#8b6914}
.auth-box label{display:block;margin:12px 0 4px;font-weight:600;color:#555}
.auth-box input[type="text"],.auth-box input[type="password"],.auth-box input[type="email"]{width:100%;padding:10px 12px;border:1.5px solid #ddd;border-radius:8px;font-size:15px;box-sizing:border-box}
.auth-box input:focus{border-color:#c9a96e;outline:none}
.code-row{display:flex;gap:8px;align-items:center}
.code-row input{flex:1}
.btn{width:100%;padding:12px;margin-top:16px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;color:#fff;background:linear-gradient(135deg,#c9a96e,#8b6914)}
.btn:hover{opacity:.9}.btn:disabled{opacity:.5;cursor:not-allowed}
.tip{font-size:13px;margin-top:6px}
.tip-ok{color:#2d8a4e}.tip-err{color:#c0392b}
.back{display:block;text-align:center;margin-top:16px;color:#999;font-size:14px;text-decoration:none}
</style>
</head>
<body>
<div class="auth-box">
<h2>Forgot Password</h2>
<div>
<label>Email</label>
<input type="email" id="email" placeholder="Enter your email">
<div class="code-row">
<input type="text" id="code" placeholder="6-digit code" maxlength="6" inputmode="numeric">
<button class="btn" id="sendBtn" style="width:auto;margin:0;padding:10px 16px">Send Code</button>
</div>
<p id="tip1" class="tip"></p>
<label>New Password</label>
<input type="password" id="newPwd" placeholder="Min 6 characters">
<label>Confirm Password</label>
<input type="password" id="confirmPwd" placeholder="Enter again">
<p id="tip2" class="tip"></p>
<button class="btn" id="resetBtn">Reset Password</button>
<a class="back" href="login.html">Back to Login</a>
</div>
</div>
<script>
var PA="/api/proxy_send_code";
var PH={"Content-Type":"application/json"};
document.getElementById("sendBtn").onclick=function(){
var email=document.getElementById("email").value.trim();
if(!email||!email.includes("@")){document.getElementById("tip1").textContent="Invalid email";document.getElementById("tip1").className="tip tip-err";return}
var btn=document.getElementById("sendBtn");btn.disabled=true;btn.textContent="Sending...";
fetch(PA+"/send_code",{method:"POST",headers:PH,body:JSON.stringify({email:email,subject:"QSONT.me - Password Reset",body:"Your code: {code}\nValid for 10 min."})})
.then(function(r){return r.json()}).then(function(d){
var t1=document.getElementById("tip1");
if(d.success){t1.textContent="Code sent!";t1.className="tip tip-ok"}
else{t1.textContent=d.error||"Failed";t1.className="tip tip-err"}
btn.disabled=false;btn.textContent="Send Code"
}).catch(function(){document.getElementById("tip1").textContent="Network error";document.getElementById("tip1").className="tip tip-err";btn.disabled=false;btn.textContent="Send Code"})
};
document.getElementById("resetBtn").onclick=function(){
var email=document.getElementById("email").value.trim();
var code=document.getElementById("code").value.trim();
var pwd=document.getElementById("newPwd").value;
var cpwd=document.getElementById("confirmPwd").value;
var t2=document.getElementById("tip2");
if(!email||!code){t2.textContent="Enter email and code";t2.className="tip tip-err";return}
if(pwd.length<6){t2.textContent="Password min 6 chars";t2.className="tip tip-err";return}
if(pwd!==cpwd){t2.textContent="Passwords don't match";t2.className="tip tip-err";return}
var btn=document.getElementById("resetBtn");btn.disabled=true;btn.textContent="Verifying...";
fetch("/api/proxy_check_code",{method:"POST",headers:PH,body:JSON.stringify({email:email,code:code})})
.then(function(r){return r.json()}).then(function(d){
if(!d.success){t2.textContent=d.error||"Invalid code";t2.className="tip tip-err";btn.disabled=false;btn.textContent="Reset Password";return}
return fetch("/api/reset_token",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:email})})
}).then(function(r){if(r)return r.json()}).then(function(d){
if(!d||!d.found){t2.textContent="Email not registered";t2.className="tip tip-err";btn.disabled=false;btn.textContent="Reset Password";return}
return fetch("/api/hash",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:pwd})})
}).then(function(r){if(r)return r.json()}).then(function(d){
if(!d||!d.hash){t2.textContent="Hash failed";t2.className="tip tip-err";btn.disabled=false;btn.textContent="Reset Password";return}
return fetch("/api/reset_token",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:email})}).then(function(r){return r.json()}).then(function(tk){return fetch("/api/reset_password",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:email,password_hash:d.hash,verify_token:tk.token})})})
}).then(function(r){if(r)return r.json()}).then(function(d){
if(d&&d.success){t2.textContent="Reset success! Redirecting...";t2.className="tip tip-ok";setTimeout(function(){location.href="login.html"},3000)}
else{t2.textContent="Reset failed";t2.className="tip tip-err"}
btn.disabled=false;btn.textContent="Reset Password"
}).catch(function(){t2.textContent="Network error";t2.className="tip tip-err";btn.disabled=false;btn.textContent="Reset Password"})
};
</script>
</body>
</html>
+47 -57
View File
@@ -1,58 +1,48 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<title>树状菜单 · 开源社区</title>
<link rel="stylesheet" href="css/theme.css">
<style>
.hero { text-align: center; margin-bottom: 1.5rem; }
.hero h1 { font-size: 1.6rem; margin-bottom: 0.4rem; }
.hero p { font-size: 0.9rem; color: var(--fg-secondary); line-height: 1.6; }
.btn-row {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-top: 1.5rem;
}
.btn-row a {
display: inline-block;
padding: 0.6rem 1.8rem;
font-size: 0.9rem;
font-weight: 600;
border-radius: var(--radius-sm);
text-decoration: none;
transition: opacity 0.15s, transform 0.1s;
}
.btn-row a:active { transform: scale(0.96); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { opacity: 0.85; }
.btn-ghost { background: transparent; color: var(--accent-dark); border: 1.5px solid var(--border); }
.btn-ghost:hover { border-color: var(--accent); opacity: 0.85; }
.tagline {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-light);
font-size: 0.8rem;
color: var(--muted);
text-align: center;
}
</style>
</head>
<body>
<div class="card">
<div class="hero">
<h1>树状菜单</h1>
<p>一个轻量的开源社区,用树的结构组织知识与文件。<br>自由生长,自由分享。</p>
</div>
<div class="btn-row">
<a class="btn-primary" href="login.html">登录</a>
<a class="btn-ghost" href="register.html">注册</a>
</div>
<div class="tagline">开源 · 轻量 · 自托管</div>
</div>
<footer class="footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
</footer>
</body>
</html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="css/theme.css">
<style>
body {
align-items: normal;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.right-group {
display: flex;
align-items: center;
}
.right-group > img,
.right-group > b,
.right-group > button {
margin-left: 5px;
margin-right: 5px;
}
</style>
<title>DNFS 欢迎来到树状笔记</title>
</head>
<body>
<header class="header">
<h1 onclick="window.location.href='index.html'">DNFS 树状笔记</h1>
<div class="right-group">
<img src="favicon.ico" alt="头像加载失败" id="avatar" width="70px" height="70px" style="border-radius: 100px;">
<b id="name">DNFS</b>
<button onclick="window.location.href='login.html'">登录</button>
<button onclick="window.location.href='register.html'">注册</button>
</div>
</header>
<div id="main-page">
<h2>推荐节点</h2>
<div id="recommended-node"></div>
</div>
<footer class="footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
</footer>
</body>
</html>
+61 -14
View File
@@ -1,3 +1,5 @@
// Session restore from localStorage
if(!sessionStorage.getItem('q4_token')){var t=localStorage.getItem('q4_token');if(t)sessionStorage.setItem('q4_token',t);}
/* by qing.
*
@@ -88,6 +90,7 @@ async function addNode(token, pid, title, content) {
input_note_title.value = "";
input_note_content.value = "";
getNode(token, stack[stack.length - 1]).then(load_notes);
getNode(token, stack[stack.length - 1]).then(loadMap)
//if (result.success) {
alert(`添加节点成功`, result);
@@ -140,6 +143,7 @@ async function deleteNode(token, id) {
stack.pop();
getNode(token, stack[stack.length - 1]).then(load_notes);
getNode(token, stack[stack.length - 1]).then(loadMap)
console.log(`删除节点成功`);
return result
@@ -169,6 +173,8 @@ var input_note_submit = document.getElementById('input-note-submit');
var input_note_clear = document.getElementById('input-note-clear');
var recommend_list = document.getElementById('recommend-list');
var delete_note = document.getElementById('delete-note');
var minimap = document.getElementById("minimap");
var is_expand = true;
input_note_submit.addEventListener('click', function(event) {
var title = input_note_title.value
@@ -219,31 +225,20 @@ function load_notes(main_node) {
recommend_list.replaceChildren();
//alert(stack.length)
if (stack.length > 0) { /* 如果不在根节点 */
var bak = document.createElement('center');
bak.addEventListener('click', function (event) {
stack.pop();
var id = (stack.length > 1) ? stack[stack.length - 1] : 0;
getNode(token, id).then(load_notes)
});
var bak1 = document.createElement('h1');
bak1.textContent = "返回上一层";
bak.appendChild(bak1)
recommend_list.appendChild(bak)
}
for (let i in main_node) {
let item = main_node[i]
let card = document.createElement('div');
let card = document.createElement('div');
card.className = "card";
card.addEventListener('click', function(event) {
var id = Number(item.id)
stack.push(id)
getNode(token, id).then(load_notes)
getNode(token, id).then(loadMap)
});
let ttl = document.createElement('center');
let ttl1 =document.createElement('h3');
ttl1.textContent = item.title;
card.id = item.title;
ttl.appendChild(ttl1);
card.appendChild(ttl);
let vit_div = document.createElement('p');
@@ -263,5 +258,57 @@ function load_notes(main_node) {
}
};
function loadMap(children_json) {
let need_return = false;
minimap.replaceChildren();
var fold_div = document.createElement("div");
fold_div.className = "tag fold-btn";
if (is_expand == true) {
fold_div.textContent = "v 折叠";
minimap.appendChild(fold_div);
} else {
fold_div.textContent = "> 展开";
minimap.appendChild(fold_div);
need_return = true;
}
fold_div.addEventListener("click", () => {
if (is_expand == true) {
is_expand = false;
getNode(token, stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap);
} else {
is_expand = true;
getNode(token, stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap);
}
});
if (need_return == true) {
return;
}
if (stack.length > 0) {
let back = document.createElement("div");
back.className = "tag back";
back.textContent = "← 返回上一层";
back.addEventListener("click", () => {
stack.pop();
var id = (stack.length >= 0) ? stack[stack.length - 1] : 0;
getNode(token, id).then(load_notes);
getNode(token, id).then(loadMap);
});
minimap.appendChild(back);
}
for (let i in children_json) {
let item = children_json[i];
let tag = document.createElement("div");
let title = item.title;
tag.className = "tag";
tag.textContent = title;
tag.addEventListener("click", () => {
window.location.href = `#${title}`;
console.log(`跳转到${title}`);
})
minimap.appendChild(tag);
}
}
var token = sessionStorage.getItem('q4_token')
getNode(token, 0).then(load_notes);
getNode(token, 0).then(loadMap)
View File
Whitespace-only changes.
+14 -86
View File
@@ -1,162 +1,90 @@
const msgTip = document.getElementById('msgTip');
const pwdTip = document.getElementById('pwdTip');
const emlTip = document.getElementById('emlTip');
const agreeTip = document.getElementById('agreeTip');
const form = document.getElementById('form');
const usernameInput = document.getElementById('loginUsername');
const passwordInput = document.getElementById('loginPassword');
const emailInput = document.getElementById('loginEmail');
const agreeBox = document.getElementById('agreeBox');
const protocol = document.getElementById('agreement');
const resetUser = document.getElementById('resetUser');
const registerBtn = document.getElementById('registerBtn');
const loginButton = document.getElementById('loginBtn');
resetUser.addEventListener('click', function() {
clearTipStyle();
});
resetUser.addEventListener('click', function() { clearTipStyle(); });
function clearTipStyle() {
msgTip.innerHTML = '';
pwdTip.innerHTML = '';
agreeTip.innerHTML = '';
emlTip.innerHTML = '';
msgTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
agreeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
protocol.classList.remove('tip-red');
protocol.classList.remove('tip-green');
agreeTip.classList.remove('tip-red');
}
clearTipStyle();
/**
* @returns {string|false}
*/
function checkUsername() {
const result = FormValidators.username(usernameInput.value);
msgTip.innerHTML = '';
msgTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
msgTip.textContent = result.message;
msgTip.classList.add(result.className);
msgTip.className = result.className;
return result.valid ? result.value : false;
}
/**
* @returns {string|false}
*/
function checkPwd() {
const result = FormValidators.password(passwordInput.value);
pwdTip.innerHTML = '';
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
pwdTip.textContent = result.message;
pwdTip.classList.add(result.className);
pwdTip.className = result.className;
return result.valid ? result.value : false;
}
/**
* @returns {string|false}
*/
function checkEml() {
const result = FormValidators.email(emailInput.value);
emlTip.innerHTML = '';
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
emlTip.textContent = result.message;
emlTip.classList.add(result.className);
return result.valid ? result.value : false;
}
/**
* @returns {boolean}
*/
function checkAgree() {
agreeTip.innerHTML = '';
agreeTip.classList.remove('tip-red');
protocol.classList.remove('tip-green', 'tip-red');
if (!agreeBox.checked) {
agreeTip.innerHTML = '请同意用户协议!';
agreeTip.classList.add('tip-red');
protocol.classList.add('tip-red');
return false;
}
protocol.classList.add('tip-green');
return true;
}
usernameInput.addEventListener('focus', checkUsername);
passwordInput.addEventListener('focus', checkPwd);
usernameInput.addEventListener('input', checkUsername);
passwordInput.addEventListener('input', checkPwd);
usernameInput.addEventListener('blur', checkUsername);
passwordInput.addEventListener('blur', checkPwd);
emailInput.addEventListener('focus', checkEml);
emailInput.addEventListener('input', checkEml);
emailInput.addEventListener('blur', checkEml);
agreeBox.addEventListener('change', checkAgree);
registerBtn.addEventListener('click', function() {
location.href = 'register.html';
});
registerBtn.addEventListener('click', function() { location.href = 'register.html'; });
form.addEventListener('submit', async function(e) {
e.preventDefault();
clearTipStyle();
const userOk = checkUsername();
const pwdOk = checkPwd();
const emlOk = checkEml();
const agreeOk = checkAgree();
if (!userOk || !pwdOk || !agreeOk) return;
if (!(userOk && pwdOk && emlOk && agreeOk)) {
return;
}
const jsonBody = JSON.stringify({
username: userOk,
password: pwdOk
});
const jsonBody = JSON.stringify({ username: userOk, password: pwdOk });
try {
loginButton.disabled = true;
const res = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json' // 告诉服务器内容为json
},
headers: {'Content-Type': 'application/json'},
body: jsonBody
});
const responseText = await res.text();
let result;
try {
result = JSON.parse(responseText);
} catch {
console.error(`登录接口返回非 JSON 内容(HTTP ${res.status}):`, responseText);
throw new Error('服务器响应格式异常,请稍后重试');
}
if (!res.ok) {
throw new Error(result.message || `登录失败(HTTP ${res.status})`);
}
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
if (!res.ok) throw new Error(result.message || '登录失败');
if (result.success) {
alert(`登陆成功`);
localStorage.setItem('q4_token', result.data.token);
sessionStorage.setItem('q4_token', result.data.token);
location.href = 'dnfs.html';
alert('登录成功');
location.href = '/dnfs.html';
} else {
alert(result.message || '登录失败');
}
} catch (err) {
alert(err.message || '接口请求失败,请检查后端服务是否启动');
console.error('登录请求异常:', err);
alert(err.message || '接口请求失败');
} finally {
loginButton.disabled = false;
}
+1 -1
View File
@@ -92,7 +92,7 @@ function checkCode() {
codeTip.classList.add('tip-yellow');
return false;
}
if (code !== sendCode) {
if (sendCode === 'email_sent') { /* Server-verified, skip client check */ } else if (code !== sendCode) {
codeTip.textContent = '验证码错误,请核对!';
codeTip.classList.add('tip-red');
return false;
+12 -17
View File
@@ -2,7 +2,6 @@
<html>
<head>
<meta charset="utf-8">
<!-- 移动端适配核心标签 -->
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<title>登录</title>
<link rel="stylesheet" href="css/theme.css">
@@ -10,22 +9,14 @@
<body>
<div class="card">
<h2>用户登录</h2>
<form action="" id="form" method="post">
<form id="form" novalidate>
<div>
<label>用户名/推荐邮箱:</label>
<input type="text" id="loginUsername" name="username" placeholder="请输入用户名">
<span id="msgTip" ></span>
<label>密码:</label>
<input type="password" id="loginPassword" name="password" placeholder="请输入密码">
<span id="pwdTip"></span>
</div>
<div>
<label>验证邮箱:</label>
<input type="email" id="loginEmail" name="email" placeholder="请输入验证邮箱">
<span id="emlSub">
<input type="submit" value="发送验证码">
</span>
<span id="emlTip"></span>
<label>用户名/推荐邮箱:</label>
<input type="text" id="loginUsername" placeholder="请输入用户名">
<span id="msgTip"></span>
<label>密码:</label>
<input type="password" id="loginPassword" placeholder="请输入密码">
<span id="pwdTip"></span>
</div>
<div class="form-actions">
<button type="button" id="registerBtn">注册</button>
@@ -38,6 +29,10 @@
<span id="agreeTip"></span>
</div>
</form>
<a href="forgot.html" style="display:block;text-align:center;color:#999;font-size:13px;margin:8px 0">忘记密码?</a>
<div style="text-align:center;margin:12px 0;position:relative"><div style="position:absolute;top:50%;left:0;right:0;height:1px;background:#e0d6cc"></div><span style="background:#fff;padding:0 12px;position:relative;color:#999;font-size:13px">或</span></div>
<a href="https://yhdet.top/yhdet_auth?callback=https://qsont.me/yhdet_callback" class="yhdet-login-btn">开源社区 快捷登录</a>
<style>.yhdet-login-btn{display:block;width:100%;padding:12px;border:1.5px solid #1a73e8;border-radius:8px;background:white;color:#1a73e8;font-size:15px;font-weight:600;text-decoration:none;text-align:center;box-sizing:border-box;margin-bottom:12px}.yhdet-login-btn:hover{background:#e8f0fe}</style>
</div>
<footer class="footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
@@ -45,4 +40,4 @@
<script src="js/validation.js"></script>
<script src="js/login.js"></script>
</body>
</html>
</html>
+1
View File
@@ -1,3 +1,4 @@
../pymsg
nuitka
Flask>=2.3.0,<3.0.0
Flask-CORS>=4.0.0,<5.0.0
+1
View File
@@ -1,5 +1,6 @@
# 编译产物
waf.bin
waf
*.exe
# 证书与密钥(不入库)
+91 -21
View File
@@ -28,17 +28,34 @@
```
rules/
├── waf.conf # 主入口,Include 各子文件
├── waf.conf # 主入口:基础配置(SecRuleEngine / SecRequestBodyLimit 等)
├── local/ # 本地自定义规则(ID 段 100000-199999)
│ ├── custom.conf # 示例:SQL注入/XSS/路径遍历/命令注入/扫描器UA
│ └── exceptions.conf # 误报白名单(SecRuleRemoveById / ctl:ruleRemoveById)
└── crs/ # OWASP CRS(git clone,不入库,ID 段 900000-999999)
└── crs/ # OWASP CRS(不入库,由 _fetch_crs.sh 拉取)
├── coraza.conf # CRS 入口(请求体处理器初始化 + tx.crs_setup_version)
├── LICENSE # Apache-2.0
└── rules/*.conf # CRS 规则文件
```
> **注意**:engine 不使用 coraza 的 `Include` 指令(其路径相对 cwd 解析易出错),改为在 [engine.Reload](internal/engine/engine.go) 中自行扫描目录、逐个 `WithDirectivesFromFile` 加载。
### 规则加载顺序
由 [engine.go:50-89](internal/engine/engine.go#L50) `Reload` 控制,固定顺序:
1. `waf.conf` — 基础配置
2. `local/*.conf` — 本地自定义规则(按文件名字母序)
3. `crs/coraza.conf` — OWASP CRS 入口(**仅当文件存在时加载**)
4. `crs/rules/*.conf` — OWASP CRS 规则(按文件名字母序)
### 规则 ID 分段
- `100000-199999` 本地自定义
- `900000-999999` OWASP CRS
| ID 段 | 用途 |
|-------|------|
| 100000-199999 | 本地自定义规则 |
| 200000-200006 | CRS 请求体处理器初始化(XML/JSON,由 `crs/coraza.conf` 定义) |
| 900000-999999 | OWASP CRS 规则(由 `crs/rules/` 提供,不手改) |
### 规则语法(SecLang)
@@ -70,10 +87,21 @@ WAF 必须终止 TLS 才能看到明文 body,因此证书需从 gateway 迁到
listen: ":443"
tls_cert: "./certs/full_chain.crt"
tls_key: "./certs/server.key"
# gateway 内部 HTTP 端口(gateway 需改为监听 127.0.0.1:8443 不启用 TLS)
upstream: "http://127.0.0.1:8443"
# 规则根目录,主入口为 <rules_dir>/waf.conf
rules_dir: "./rules"
```
| 字段 | 默认值 | 说明 |
|------|--------|------|
| `listen` | `:443` | 监听地址 |
| `tls_cert` / `tls_key` | — | TLS 证书与私钥路径 |
| `upstream` | — | gateway 地址,**必填** |
| `rules_dir` | `./rules` | 规则根目录 |
## 目录结构
```
@@ -83,7 +111,7 @@ waf/
├── go.mod
├── internal/
│ ├── engine/
│ │ └── engine.go # coraza 封装:加载/热加载/列举/单测
│ │ └── engine.go # coraza 封装:加载/热加载/列举/单测/输入检测
│ └── proxy/
│ └── proxy.go # ReverseProxy + WAF 事务接入
├── rules/
@@ -91,8 +119,10 @@ waf/
│ └── local/
│ ├── custom.conf
│ └── exceptions.conf
├── _set.sh # 编译
├── _set.sh # 编译(含 go mod tidy + download + build)
├── _run.sh # 运行
├── _goproxy.sh # 设置 GOPROXY 国内镜像(由 _set.sh 调用)
├── _fetch_crs.sh # 拉取 OWASP CRS(可选)
├── .gitignore
└── README.md
```
@@ -100,11 +130,11 @@ waf/
## 安装
```bash
# 1. 安装 Go 1.21+
# 1. 安装 Go 1.21+(实际要求 1.22+,见 go.mod)
brew install go # macOS
# 或 sudo apt install golang-go # Debian/Ubuntu
# 2. 编译
# 2. 编译(_set.sh 会自动设置 GOPROXY 国内镜像 + GOTOOLCHAIN=local)
bash _set.sh
# 3. 准备证书
@@ -114,6 +144,8 @@ mkdir certs && cp /path/to/full_chain.crt /path/to/server.key certs/
bash _run.sh
```
> **国内环境**:[_set.sh](_set.sh) 会调用 [_goproxy.sh](_goproxy.sh) 自动设置 `GOPROXY=https://goproxy.cn,direct` 与 `GOSUMDB=sum.golang.google.cn`;并强制 `GOTOOLCHAIN=local` 避免 Go 1.21+ 自动从 `proxy.golang.org` 下载 toolchain 超时。用户已显式配置非默认代理时尊重不覆盖。
## 接入 core 自启动
在 `core/init.txt` 增加:
@@ -126,13 +158,19 @@ bash /opt/micro_servers/waf/_run.sh
## 命中日志
命中规则时输出 JSON 一行,便于 `jq` 分析:
命中规则时输出 JSON 一行,便于 `jq` 分析(见 [proxy.go:120-137](internal/proxy/proxy.go#L120)):
```json
{"ts":"2026-07-25T10:00:00Z","action":"block","rule_id":100001,"ip":"1.2.3.4","method":"GET","path":"/","query":"id=1'+OR+'1'='1","ua":"curl/8.0","cost_ms":0}
```
聚合统计:
放行请求时输出一行 pass 日志(见 [proxy.go:115](internal/proxy/proxy.go#L115)):
```
[proxy] pass ip=1.2.3.4:54321 method=GET path=/ status=200 rule=- cost=1.2ms
```
聚合统计命中规则:
```bash
journalctl -u Nidus -o cat | grep '"action":"block"' | \
@@ -141,31 +179,63 @@ journalctl -u Nidus -o cat | grep '"action":"block"' | \
## 启用 OWASP CRS(可选)
通过 Go module proxy 拉取(不依赖 GitHub 直连,国内可用):
```bash
cd rules
git clone --depth 1 https://github.com/coreruleset/coreruleset.git crs
bash _fetch_crs.sh # 拉取最新版
# 或
bash _fetch_crs.sh v4.25.0 # 拉取指定版本
```
在 `rules/waf.conf` 取消注释:
脚本会:
```apache
Include crs/coraza.conf
Include crs/rules/*.conf
1. 通过 `GOPROXY=https://goproxy.cn,direct` 下载 `github.com/corazawaf/coraza-coreruleset/v4` 到模块缓存
2. 复制规则文件(`*.conf` + `*.data`)到 `rules/crs/rules/`
3. 自动生成 `rules/crs/coraza.conf`(含请求体处理器初始化规则 200000-200006 与 `tx.crs_setup_version`)
4. 复制 Apache-2.0 LICENSE
完成后 `rules/crs/coraza.conf` 存在,[engine.Reload](internal/engine/engine.go) 会**自动检测并加载**,无需改 `waf.conf`。直接热加载即可生效:
```bash
kill -HUP $(pgrep waf)
```
然后 `kill -HUP $(pgrep waf)`。
校验加载结果:
```bash
./waf.bin -list | head
```
> `rules/crs/` 整个目录已在 `.gitignore` 中,不入库。如需升级 CRS,重跑 `_fetch_crs.sh` 即可。
## 请求处理流程
[proxy.go:57-117](internal/proxy/proxy.go#L57) `ServeHTTP`:
1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host)
2. `ProcessRequestHeaders` → 命中即 `block` 返回 403
3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway
5. 捕获响应头 → `ProcessResponseHeaders`
Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。
ErrorHandler([proxy.go:48-51](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,不暴露内部堆栈,仅服务端 log。
## 已知限制
- 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。
- 响应体检测未启用(需缓冲整个响应,延迟成本高)。
- **响应体检测未启用**:仅 `ProcessResponseHeaders`,未 `ProcessResponseBody`(缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
- `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
## 依赖
- Go 1.21+
- github.com/corazawaf/coraza/v3 v3.7.0
- gopkg.in/yaml.v3
- Go 1.22+(见 [go.mod](go.mod),锁 `go 1.22.0`)
- `github.com/corazawaf/coraza/v3` **v3.3.0**(锁版本,避免 v3.7.0 要求 Go 1.25+)
- `gopkg.in/yaml.v3` v3.0.1
## 许可证
+5 -3
View File
@@ -11,6 +11,8 @@ import (
"github.com/corazawaf/coraza/v3"
"github.com/corazawaf/coraza/v3/types"
"github.com/micro_servers/waf/internal/logclient"
)
// Engine 持有当前生效的 WAF 实例,支持通过 Reload 热替换。
@@ -257,8 +259,8 @@ func TestFile(file string) error {
return nil
}
// LogError 默认错误回调,输出到 stderr。
// LogError 默认错误回调,推送到日志库。
func LogError(mr types.MatchedRule) {
fmt.Fprintf(os.Stderr, "[coraza] rule_id=%d severity=%s msg=%s\n",
mr.Rule().ID(), mr.Rule().Severity(), mr.ErrorLog())
logclient.Error("waf", fmt.Sprintf("coraza rule_id=%d severity=%s", mr.Rule().ID(), mr.Rule().Severity()),
map[string]any{"rule_id": mr.Rule().ID(), "msg": mr.ErrorLog()})
}
+269
View File
@@ -0,0 +1,269 @@
// Package logclient 提供异步批量日志推送客户端。
//
// 通过 pymsg TCP 协议调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
//
// 设计要点(与 Python example_log_client.py 对齐):
// - 异步非阻塞:内存 channel + 后台 goroutine 批量推送,不拖慢业务
// - fail-safe:db 服务不可用时静默丢弃,绝不影响业务
// - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
//
// 协议:pymsg 长度前缀帧(4 字节 big-endian uint32 长度 + UTF-8 载荷)
package logclient
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"sync"
"time"
)
const (
dbAddr = "127.0.0.1:8005"
batchSize = 50
flushInterval = 2 * time.Second
socketTimeout = 5 * time.Second
)
// Entry 单条日志。
type Entry struct {
Service string `json:"service"`
Level string `json:"level"`
Message string `json:"message"`
TraceID string `json:"trace_id,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
// dbV2Request execute 路由的 INSERT 请求体。
type dbV2Request struct {
Route string `json:"_route"`
Action string `json:"action"`
Table string `json:"table"`
Values map[string]any `json:"values"`
}
// dbV2Response execute 路由的通用响应。
type dbV2Response struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// Client 异步批量日志推送客户端。
type Client struct {
ch chan Entry
stop chan struct{}
wg sync.WaitGroup
stopped bool
mu sync.Mutex
}
// global 全局单例。
var global *Client
// Init 初始化全局日志客户端并启动后台 flush goroutine。
func Init() {
global = New()
}
// New 创建客户端实例。
func New() *Client {
c := &Client{
ch: make(chan Entry, 256),
stop: make(chan struct{}),
}
c.wg.Add(1)
go c.flushLoop()
return c
}
// Push 推送一条日志(非阻塞,队列满时丢弃)。
func Push(service, level, message string, extra map[string]any) {
if global == nil {
return
}
global.push(Entry{
Service: service,
Level: level,
Message: message,
Extra: extra,
})
}
// Info 推送 INFO 级别日志。
func Info(service, message string, extra map[string]any) {
Push(service, "INFO", message, extra)
}
// Warning 推送 WARNING 级别日志。
func Warning(service, message string, extra map[string]any) {
Push(service, "WARNING", message, extra)
}
// Error 推送 ERROR 级别日志。
func Error(service, message string, extra map[string]any) {
Push(service, "ERROR", message, extra)
}
// Flush 同步 flush 队列中剩余日志(用于进程退出前)。
func Flush() {
if global == nil {
return
}
global.flush()
}
func (c *Client) push(e Entry) {
select {
case c.ch <- e:
default:
// 队列满,静默丢弃(fail-safe)
}
}
func (c *Client) flushLoop() {
defer c.wg.Done()
batch := make([]Entry, 0, batchSize)
ticker := time.NewTicker(flushInterval)
defer ticker.Stop()
for {
select {
case e := <-c.ch:
batch = append(batch, e)
// 非阻塞地继续取,直到满 batchSize
for len(batch) < batchSize {
select {
case e2 := <-c.ch:
batch = append(batch, e2)
default:
goto send
}
}
send:
if len(batch) >= batchSize {
c.send(batch)
batch = batch[:0]
}
case <-ticker.C:
if len(batch) > 0 {
c.send(batch)
batch = batch[:0]
}
case <-c.stop:
// 退出前 drain channel
for {
select {
case e := <-c.ch:
batch = append(batch, e)
default:
if len(batch) > 0 {
c.send(batch)
}
return
}
}
}
}
}
func (c *Client) send(batch []Entry) {
now := time.Now().UTC().Format(time.RFC3339)
for _, entry := range batch {
c.sendOne(entry, now)
}
}
// sendOne 通过 pymsg TCP 协议发送单条日志到 dbv2。
func (c *Client) sendOne(entry Entry, ts string) {
extraJSON := ""
if entry.Extra != nil {
b, err := json.Marshal(entry.Extra)
if err != nil {
return
}
extraJSON = string(b)
}
values := map[string]any{
"ts": ts,
"service": entry.Service,
"level": entry.Level,
"message": entry.Message,
}
if entry.TraceID != "" {
values["trace_id"] = entry.TraceID
}
if extraJSON != "" {
values["extra"] = extraJSON
}
req := dbV2Request{
Route: "execute",
Action: "insert",
Table: "Logs",
Values: values,
}
data, err := json.Marshal(req)
if err != nil {
return
}
// pymsg 协议:4 字节 big-endian 长度 + UTF-8 载荷
resp, err := pymsgCall(data)
if err != nil {
return // fail-safe
}
var r dbV2Response
if json.Unmarshal(resp, &r) != nil || !r.Success {
fmt.Printf("[logclient] INSERT failed: %s\n", r.Error)
}
}
// pymsgCall 发送一条 pymsg 请求并读取响应(短连接模式)。
func pymsgCall(payload []byte) ([]byte, error) {
conn, err := net.DialTimeout("tcp", dbAddr, socketTimeout)
if err != nil {
return nil, err
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(socketTimeout))
// 发送:4 字节长度前缀 + 载荷
header := make([]byte, 4)
binary.BigEndian.PutUint32(header, uint32(len(payload)))
if _, err := conn.Write(header); err != nil {
return nil, err
}
if _, err := conn.Write(payload); err != nil {
return nil, err
}
// 接收:4 字节长度前缀 + 载荷
if _, err := io.ReadFull(conn, header); err != nil {
return nil, err
}
respLen := binary.BigEndian.Uint32(header)
resp := make([]byte, respLen)
if _, err := io.ReadFull(conn, resp); err != nil {
return nil, err
}
return resp, nil
}
func (c *Client) flush() {
c.mu.Lock()
if c.stopped {
c.mu.Unlock()
return
}
c.stopped = true
c.mu.Unlock()
close(c.stop)
c.wg.Wait()
}
+15 -21
View File
@@ -3,9 +3,8 @@ package proxy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httputil"
"net/url"
@@ -14,6 +13,7 @@ import (
"time"
"github.com/micro_servers/waf/internal/engine"
"github.com/micro_servers/waf/internal/logclient"
)
// Proxy 持有 ReverseProxy 与 WAF 引擎引用。
@@ -46,7 +46,8 @@ func New(upstream string, waf *engine.Engine) (*Proxy, error) {
}
// 透传错误,不暴露内部堆栈
rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("[proxy] upstream_error ip=%s path=%s err=%v", r.RemoteAddr, r.URL.Path, err)
logclient.Error("waf", fmt.Sprintf("upstream_error path=%s err=%v", r.URL.Path, err),
map[string]any{"ip": r.RemoteAddr})
http.Error(w, "upstream unavailable", http.StatusBadGateway)
}
return p, nil
@@ -111,27 +112,20 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1")
log.Printf("[proxy] pass ip=%s method=%s path=%s status=%d rule=- cost=%s",
r.RemoteAddr, r.Method, r.URL.Path, rw.status, time.Since(start))
}
// block 命中规则时统一返回 403,并打 JSON 日志便于 jq 分析。
// block 命中规则时统一返回 403,并将关键输入推送到日志库。
func (p *Proxy) block(w http.ResponseWriter, r *http.Request, ruleID int, start time.Time) {
rec := map[string]any{
"ts": time.Now().UTC().Format(time.RFC3339),
"action": "block",
"rule_id": ruleID,
"ip": r.RemoteAddr,
"method": r.Method,
"path": r.URL.Path,
"query": r.URL.RawQuery,
"ua": r.UserAgent(),
"cost_ms": time.Since(start).Milliseconds(),
}
if raw, err := json.Marshal(rec); err == nil {
log.Println(string(raw))
}
logclient.Warning("waf", fmt.Sprintf("block rule_id=%d %s %s", ruleID, r.Method, r.URL.Path),
map[string]any{
"rule_id": ruleID,
"ip": r.RemoteAddr,
"method": r.Method,
"path": r.URL.Path,
"query": r.URL.RawQuery,
"ua": r.UserAgent(),
"cost_ms": time.Since(start).Milliseconds(),
})
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("blocked by WAF\n"))
}
+15 -9
View File
@@ -16,6 +16,7 @@ import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
@@ -25,6 +26,7 @@ import (
"gopkg.in/yaml.v3"
"github.com/micro_servers/waf/internal/engine"
"github.com/micro_servers/waf/internal/logclient"
"github.com/micro_servers/waf/internal/proxy"
)
@@ -82,9 +84,14 @@ func main() {
}
// 服务模式
logclient.Init()
log.SetOutput(io.Discard) // 静默标准 log
p, err := proxy.New(cfg.Upstream, eng)
if err != nil {
log.Fatalf("代理初始化失败: %v", err)
logclient.Error("waf", fmt.Sprintf("代理初始化失败: %v", err), nil)
logclient.Flush()
os.Exit(1)
}
// SIGHUP 热加载
@@ -92,11 +99,10 @@ func main() {
signal.Notify(sigCh, syscall.SIGHUP)
go func() {
for range sigCh {
log.Println("[reload] 收到 SIGHUP,重新加载规则")
if err := eng.Reload(); err != nil {
log.Printf("[reload] 失败,保留旧规则: %v", err)
logclient.Error("waf", fmt.Sprintf("热加载失败,保留旧规则: %v", err), nil)
} else {
log.Println("[reload] 成功")
logclient.Info("waf", "规则热加载成功", nil)
}
}
}()
@@ -109,13 +115,13 @@ func main() {
},
}
log.Printf("waf listening on %s, upstream=%s, rules=%s",
cfg.Listen, cfg.Upstream, cfg.RulesDir)
log.Printf(" - 热加载: kill -HUP %d", os.Getpid())
log.Printf(" - 列规则: %s -c %s -list", os.Args[0], *configFile)
logclient.Info("waf", fmt.Sprintf("服务启动 listen=%s upstream=%s rules=%s pid=%d",
cfg.Listen, cfg.Upstream, cfg.RulesDir, os.Getpid()), nil)
if err := server.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != nil {
log.Fatal(err)
logclient.Error("waf", fmt.Sprintf("TLS 监听失败: %v", err), nil)
logclient.Flush()
os.Exit(1)
}
}