2026-09-16 17:15:48 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""SVFS 插件服务端的最小示例(FastAPI)。
|
|
|
|
|
|
|
|
|
|
|
|
一个进程同时承担客户端的两件事:
|
|
|
|
|
|
|
|
|
|
|
|
1. **插件下载**:把每个插件的 publish 产物(``manifest.json`` + ``blobs/``)
|
|
|
|
|
|
放在 ``PLUGINS_DIR/<插件id>/`` 下并由静态文件服务吐出——客户端
|
|
|
|
|
|
「从服务器安装」填插件 id 即可(对象逐个 sha256 校验);
|
|
|
|
|
|
2. **接口服务**:``/api/*`` 是普通 HTTP API,插件经
|
|
|
|
|
|
``svfs.net.request({path: "/api/ping"})`` 调用。宿主代发请求,
|
|
|
|
|
|
不经过浏览器的 CORS,服务端不需要任何跨域配置。
|
|
|
|
|
|
``/api/*`` 需要登录:客户端登录界面调 ``POST /api/login`` 换 token,
|
|
|
|
|
|
之后宿主自动给发往本服务器的请求附上 ``Authorization: Bearer``。
|
|
|
|
|
|
|
|
|
|
|
|
运行:
|
|
|
|
|
|
|
|
|
|
|
|
.. code-block:: bash
|
|
|
|
|
|
|
|
|
|
|
|
uv sync --extra fastapi # fastapi + starlette(本仓库的 extra)
|
|
|
|
|
|
uv pip install uvicorn # 服务器(示例直接用 uvicorn 起进程)
|
|
|
|
|
|
uv run python examples/plugin_server.py
|
|
|
|
|
|
# → http://127.0.0.1:8000 (客户端「服务器」卡片填这个地址)
|
|
|
|
|
|
|
|
|
|
|
|
发布一个插件(也可以用下面的 POST /plugins/<id> 接口上传):
|
|
|
|
|
|
|
|
|
|
|
|
.. code-block:: bash
|
|
|
|
|
|
|
|
|
|
|
|
svfs pack ./my-plugin dist.svfs --compress
|
|
|
|
|
|
svfs publish dist.svfs svfs-plugins-root/com.example.my-plugin
|
|
|
|
|
|
# 客户端「从服务器安装」填:com.example.my-plugin
|
|
|
|
|
|
|
|
|
|
|
|
目录布局(客户端约定,勿改):
|
|
|
|
|
|
|
|
|
|
|
|
.. code-block:: text
|
|
|
|
|
|
|
|
|
|
|
|
PLUGINS_DIR/
|
|
|
|
|
|
└── <插件id>/
|
|
|
|
|
|
├── manifest.json
|
|
|
|
|
|
└── blobs/<hash 前 2 位>/<hash>
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-17 01:06:02 +08:00
|
|
|
|
import json
|
2026-09-16 17:15:48 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import secrets
|
|
|
|
|
|
import sqlite3
|
|
|
|
|
|
import tempfile
|
2026-09-17 01:06:02 +08:00
|
|
|
|
import threading
|
2026-09-16 17:15:48 +08:00
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from pathlib import Path
|
2026-09-17 01:06:02 +08:00
|
|
|
|
from typing import Optional
|
2026-09-16 17:15:48 +08:00
|
|
|
|
|
|
|
|
|
|
from fastapi import Depends, FastAPI, File, Header, HTTPException, Request, UploadFile
|
|
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
|
|
|
|
|
|
from sqlite_vfs.core import SQLiteVFS
|
|
|
|
|
|
from sqlite_vfs.publish import export_objects
|
|
|
|
|
|
from svfs_client.manifest import ManifestError, read_from_vfs
|
2026-09-17 01:06:02 +08:00
|
|
|
|
from svfs_client.topics import (
|
|
|
|
|
|
broadcast_message_topic,
|
|
|
|
|
|
user_message_topic,
|
|
|
|
|
|
user_plugin_topic,
|
|
|
|
|
|
)
|
2026-09-16 17:15:48 +08:00
|
|
|
|
|
|
|
|
|
|
#: 插件包根:每个 <插件id>/ 一个子目录,里面是 svfs publish 的产物
|
|
|
|
|
|
PLUGINS_DIR = Path(os.environ.get("SVFS_PLUGINS_DIR", "./svfs-plugins-root")).resolve()
|
|
|
|
|
|
PLUGINS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI(title="SVFS 插件服务", version="0.1.0")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
# 登录认证:客户端启动时经登录界面换取 token,之后访问 /api/* 都带上它
|
|
|
|
|
|
# (会话在内存里,重启服务端后客户端会收到 401 并被要求重新登录)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#: 演示账号:未设置 ``SVFS_USERS`` 环境变量时的兜底
|
|
|
|
|
|
DEMO_USERS = {"admin": "admin123"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_users():
|
|
|
|
|
|
"""从 ``SVFS_USERS``(``user:pass,user2:pass2`` 格式)读账号表,每次请求现读,
|
|
|
|
|
|
改环境变量即时生效;未设置(或置空)时用内置演示账号 admin / admin123。"""
|
|
|
|
|
|
raw = os.environ.get("SVFS_USERS", "")
|
|
|
|
|
|
users = {}
|
|
|
|
|
|
for item in raw.split(","):
|
|
|
|
|
|
item = item.strip()
|
|
|
|
|
|
if not item:
|
|
|
|
|
|
continue
|
|
|
|
|
|
name, _, password = item.partition(":")
|
|
|
|
|
|
name, password = name.strip(), password.strip()
|
|
|
|
|
|
if name and password:
|
|
|
|
|
|
users[name] = password
|
|
|
|
|
|
return users or DEMO_USERS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#: token -> 会话(username / login_time;进程内存会话,重启即失效)
|
|
|
|
|
|
SESSIONS = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_auth(authorization: str = Header(default="")):
|
|
|
|
|
|
"""FastAPI 依赖:校验 ``Authorization: Bearer <token>``,通过返回会话 dict。"""
|
|
|
|
|
|
token = authorization[7:].strip() if authorization.startswith("Bearer ") else ""
|
|
|
|
|
|
session = SESSIONS.get(token)
|
|
|
|
|
|
if not session:
|
|
|
|
|
|
raise HTTPException(status_code=401, detail="未登录或登录已过期")
|
|
|
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LoginBody(BaseModel):
|
|
|
|
|
|
username: str
|
|
|
|
|
|
password: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/login")
|
|
|
|
|
|
def login(body: LoginBody):
|
|
|
|
|
|
"""客户端登录界面调用:账号密码换 token。"""
|
|
|
|
|
|
if _load_users().get(body.username) != body.password or not body.password:
|
|
|
|
|
|
raise HTTPException(status_code=401, detail="用户名或密码不正确")
|
|
|
|
|
|
token = secrets.token_hex(32)
|
|
|
|
|
|
login_time = datetime.now().isoformat(timespec="seconds")
|
|
|
|
|
|
SESSIONS[token] = {"username": body.username, "login_time": login_time}
|
2026-09-17 01:06:02 +08:00
|
|
|
|
return {"ok": True, "token": token, **_session_extra(body.username, login_time)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _session_extra(username, login_time):
|
|
|
|
|
|
"""登录/校验响应里的额外字段:组织标识 + broker 地址(有才带)。"""
|
|
|
|
|
|
extra = {"username": username, "login_time": login_time, "org": _org()}
|
|
|
|
|
|
mqtt = _mqtt_announcement()
|
|
|
|
|
|
if mqtt:
|
|
|
|
|
|
extra["mqtt"] = mqtt
|
|
|
|
|
|
return extra
|
2026-09-16 17:15:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/me")
|
|
|
|
|
|
def me(session: dict = Depends(require_auth)):
|
2026-09-17 01:06:02 +08:00
|
|
|
|
"""客户端启动时校验记住的 token 是否仍然有效。
|
|
|
|
|
|
|
|
|
|
|
|
这里也要下发组织与 broker:静默校验通过后就**不再弹登录窗**,是客户端
|
|
|
|
|
|
拿到推送配置的另一条主路径。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return {"ok": True, **_session_extra(session["username"],
|
|
|
|
|
|
session.get("login_time", ""))}
|
2026-09-16 17:15:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/logout")
|
|
|
|
|
|
def logout(authorization: str = Header(default="")):
|
|
|
|
|
|
"""退出登录:作废当前 token。"""
|
|
|
|
|
|
token = authorization[7:].strip() if authorization.startswith("Bearer ") else ""
|
|
|
|
|
|
SESSIONS.pop(token, None)
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
# 接口服务:插件经 svfs.net.request 调用(随你自己的业务随意增删)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/ping")
|
|
|
|
|
|
def ping(session: dict = Depends(require_auth)):
|
|
|
|
|
|
"""演示插件的「请求 /api/ping」按钮打的就是这里。
|
|
|
|
|
|
|
|
|
|
|
|
需要登录(Bearer token):客户端桥会给发往本服务器的 net 请求
|
|
|
|
|
|
自动带上登录 token,插件代码不用关心认证。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return {"pong": True, "user": session["username"],
|
|
|
|
|
|
"time": datetime.now().isoformat(timespec="seconds")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/echo")
|
|
|
|
|
|
async def echo(request: Request, session: dict = Depends(require_auth)):
|
|
|
|
|
|
"""演示 POST + JSON body:插件把 body 作为字符串放进 net 请求。"""
|
|
|
|
|
|
body = await request.json()
|
|
|
|
|
|
return {"received": body, "ok": True, "user": session["username"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
# 插件上传:收一个 .svfs,服务端完成 publish(对象 + 清单)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _check_upload_token(request_token):
|
|
|
|
|
|
"""可选的上传令牌:设置环境变量 ``SVFS_UPLOAD_TOKEN`` 后,
|
|
|
|
|
|
上传必须带 ``X-SVFS-Token`` 头(每次请求现读,改环境变量即时生效)。"""
|
|
|
|
|
|
expected = os.environ.get("SVFS_UPLOAD_TOKEN", "")
|
|
|
|
|
|
if expected and request_token != expected:
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="上传令牌不正确")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/plugins/{plugin_id}")
|
|
|
|
|
|
async def upload_plugin(plugin_id: str, file: UploadFile = File(...),
|
|
|
|
|
|
x_svfs_token: str = Header(default="")):
|
|
|
|
|
|
"""上传一个插件包并就地发布。
|
|
|
|
|
|
|
|
|
|
|
|
服务端做三件事:读 plugin.json 校验清单(与客户端同一套规则)、
|
|
|
|
|
|
确认清单 id 与 URL 一致、用 ``export_objects`` 导出成
|
|
|
|
|
|
``manifest.json + blobs/``(内容寻址,重复上传自动去重)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
_check_upload_token(x_svfs_token)
|
|
|
|
|
|
data = await file.read()
|
|
|
|
|
|
|
|
|
|
|
|
handle_fd, tmp_path = tempfile.mkstemp(suffix=".svfs")
|
|
|
|
|
|
try:
|
|
|
|
|
|
with os.fdopen(handle_fd, "wb") as handle:
|
|
|
|
|
|
handle.write(data)
|
|
|
|
|
|
try:
|
|
|
|
|
|
vfs = SQLiteVFS(tmp_path, readonly=True)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"不是有效的 .svfs 包: {exc}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
try:
|
|
|
|
|
|
manifest = read_from_vfs(vfs)
|
|
|
|
|
|
except ManifestError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
|
|
|
|
report = vfs.verify()
|
|
|
|
|
|
if not report["ok"]:
|
|
|
|
|
|
raise HTTPException(status_code=400,
|
|
|
|
|
|
detail="包完整性校验未通过: " + ";".join(report["errors"][:3]))
|
|
|
|
|
|
finally:
|
|
|
|
|
|
vfs.close()
|
|
|
|
|
|
|
|
|
|
|
|
if manifest["id"] != plugin_id:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"清单 id ({manifest['id']}) 与 URL 里的插件 id ({plugin_id}) 不一致",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
stats = export_objects(tmp_path, str(PLUGINS_DIR / plugin_id), overwrite=True)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if os.path.exists(tmp_path):
|
|
|
|
|
|
os.unlink(tmp_path)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": True,
|
|
|
|
|
|
"id": manifest["id"],
|
|
|
|
|
|
"version": manifest["version"],
|
|
|
|
|
|
"files": stats["files"],
|
|
|
|
|
|
"digest": stats["digest"],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 01:06:02 +08:00
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
# 消息:服务端主动推给用户(MQTT),登录后客户端先同步一次补齐
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
#
|
|
|
|
|
|
# 主题方案(组织标识在第一版就占好位子,多租户上线不用改格式):
|
|
|
|
|
|
#
|
|
|
|
|
|
# <prefix>/<org>/user/<用户名>/message 主机消息 → 客户端的消息中心
|
|
|
|
|
|
# <prefix>/<org>/user/<用户名>/plugin/<插件id> 插件消息 → 只给该插件
|
|
|
|
|
|
# <prefix>/<org>/broadcast/message 组织广播 → 消息中心
|
|
|
|
|
|
#
|
|
|
|
|
|
# 服务端与客户端用同一套拼主题的函数(直接 import svfs_client.topics),
|
|
|
|
|
|
# 用户名里的特殊字符两端编码一致,不会错位。
|
|
|
|
|
|
#
|
|
|
|
|
|
# 演示跑法(需要一个 broker,比如 mosquitto):
|
|
|
|
|
|
#
|
|
|
|
|
|
# SVFS_MQTT_HOST=127.0.0.1 SVFS_ORG=acme uv run python examples/plugin_server.py
|
|
|
|
|
|
# # 客户端登录后会自动连上(broker 地址由登录响应下发),然后:
|
|
|
|
|
|
# curl -X POST http://127.0.0.1:8000/api/push -H "Authorization: Bearer $token" \
|
|
|
|
|
|
# -H "Content-Type: application/json" \
|
|
|
|
|
|
# -d '{"kind":"message","title":"备份完成","body":"3 个包","level":"info"}'
|
|
|
|
|
|
# # 插件数据(不进消息中心):
|
|
|
|
|
|
# curl -X POST http://127.0.0.1:8000/api/push -H "Authorization: Bearer $token" \
|
|
|
|
|
|
# -H "Content-Type: application/json" \
|
|
|
|
|
|
# -d '{"kind":"plugin","plugin":"com.svfs.demo","data":{"cpu":0.42}}'
|
|
|
|
|
|
#
|
|
|
|
|
|
# 没配 broker 时 /api/push 只写待收列表(响应里如实说明 mqtt=skipped),
|
|
|
|
|
|
# 客户端仍能靠登录后的同步看到消息——这条降级路径让演示不依赖 broker。
|
|
|
|
|
|
|
|
|
|
|
|
#: 每个用户的待收消息上限(演示用;生产该入库)
|
|
|
|
|
|
MESSAGE_BACKLOG = 100
|
|
|
|
|
|
#: 待收列表:username → [消息, …](内存,重启即清空)
|
|
|
|
|
|
MESSAGES: dict = {}
|
|
|
|
|
|
#: 同一条消息可能被多个客户端(同账号多端)各收一次,所以按用户存
|
|
|
|
|
|
_mqtt_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mqtt_prefix():
|
|
|
|
|
|
return os.environ.get("SVFS_MQTT_PREFIX", "svfs").strip() or "svfs"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _org():
|
|
|
|
|
|
return os.environ.get("SVFS_ORG", "").strip() or "default"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mqtt_announcement():
|
|
|
|
|
|
"""登录响应里下发 broker 信息(客户端据此自动订阅,不用用户手填)。
|
|
|
|
|
|
|
|
|
|
|
|
没配 ``SVFS_MQTT_HOST`` 时返回 None——客户端就走"只同步、不推送"的降级。
|
|
|
|
|
|
"""
|
|
|
|
|
|
host = os.environ.get("SVFS_MQTT_HOST", "").strip()
|
|
|
|
|
|
if not host:
|
|
|
|
|
|
return None
|
|
|
|
|
|
tls = os.environ.get("SVFS_MQTT_TLS", "").strip().lower() in ("1", "true", "yes")
|
|
|
|
|
|
announcement = {"host": host, "tls": tls,
|
|
|
|
|
|
"prefix": _mqtt_prefix(), "org": _org()}
|
|
|
|
|
|
port = os.environ.get("SVFS_MQTT_PORT", "").strip()
|
|
|
|
|
|
if port.isdigit():
|
|
|
|
|
|
announcement["port"] = int(port)
|
|
|
|
|
|
return announcement
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mqtt_publish(topic, payload):
|
|
|
|
|
|
"""发一条到 broker;没配 broker / 没装 paho 都返回说明字符串(不抛)。"""
|
|
|
|
|
|
host = os.environ.get("SVFS_MQTT_HOST", "").strip()
|
|
|
|
|
|
if not host:
|
|
|
|
|
|
return "skipped: 未配置 SVFS_MQTT_HOST(消息只进了待收列表)"
|
|
|
|
|
|
try:
|
|
|
|
|
|
import paho.mqtt.client as mqtt
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return "skipped: 服务端没装 paho-mqtt(pip install paho-mqtt)"
|
|
|
|
|
|
|
|
|
|
|
|
tls = os.environ.get("SVFS_MQTT_TLS", "").strip().lower() in ("1", "true", "yes")
|
|
|
|
|
|
port = int(os.environ.get("SVFS_MQTT_PORT", "") or (8883 if tls else 1883))
|
|
|
|
|
|
kwargs = {"client_id": f"svfs-server-{secrets.token_hex(4)}"}
|
|
|
|
|
|
api_version = getattr(mqtt, "CallbackAPIVersion", None)
|
|
|
|
|
|
if api_version is not None: # paho 2.x 要求显式声明回调 API 版本
|
|
|
|
|
|
kwargs["callback_api_version"] = api_version.VERSION2
|
|
|
|
|
|
client = mqtt.Client(**kwargs)
|
|
|
|
|
|
if tls:
|
|
|
|
|
|
client.tls_set()
|
|
|
|
|
|
try:
|
|
|
|
|
|
with _mqtt_lock: # 演示用:一次一条,连上→发→断开
|
|
|
|
|
|
client.connect(host, port, 10)
|
|
|
|
|
|
client.loop_start()
|
|
|
|
|
|
info = client.publish(topic, payload, qos=0)
|
|
|
|
|
|
info.wait_for_publish(timeout=5)
|
|
|
|
|
|
client.loop_stop()
|
|
|
|
|
|
client.disconnect()
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
return f"error: {exc}"
|
|
|
|
|
|
return "published"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PushBody(BaseModel):
|
|
|
|
|
|
"""推送请求:``kind`` 决定走哪条通道。"""
|
|
|
|
|
|
|
|
|
|
|
|
kind: str = "message" # message = 给人看的;plugin = 给插件的业务载荷
|
|
|
|
|
|
user: str = "" # 目标用户(空 = 发给调用者自己)
|
|
|
|
|
|
plugin: str = "" # kind=plugin 时必填:目标插件 id
|
|
|
|
|
|
title: str = ""
|
|
|
|
|
|
body: str = ""
|
|
|
|
|
|
level: str = "info"
|
|
|
|
|
|
data: Optional[dict] = None # kind=plugin 的载荷(原样发给插件)
|
|
|
|
|
|
id: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/messages")
|
|
|
|
|
|
def list_messages(since: str = "", session: dict = Depends(require_auth)):
|
|
|
|
|
|
"""客户端登录后/点「立即同步」时补齐消息(``since`` = 本地最新一条的时间)。"""
|
|
|
|
|
|
items = list(MESSAGES.get(session["username"], []))
|
|
|
|
|
|
if since:
|
|
|
|
|
|
items = [item for item in items if str(item.get("time") or "") > since]
|
|
|
|
|
|
return {"ok": True, "messages": items}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/push")
|
|
|
|
|
|
def push_message(body: PushBody, session: dict = Depends(require_auth)):
|
|
|
|
|
|
"""服务端主动推一条:写待收列表(离线也能补齐)+ 发到 MQTT(在线实时到)。"""
|
|
|
|
|
|
target = (body.user or session["username"]).strip()
|
|
|
|
|
|
if not target:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="缺少目标用户")
|
|
|
|
|
|
|
|
|
|
|
|
if body.kind == "plugin":
|
|
|
|
|
|
if not body.plugin:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="kind=plugin 需要 plugin 字段")
|
|
|
|
|
|
topic = user_plugin_topic(_mqtt_prefix(), _org(), target, body.plugin)
|
|
|
|
|
|
payload = json.dumps(body.data if body.data is not None else {},
|
|
|
|
|
|
ensure_ascii=False)
|
|
|
|
|
|
# 插件数据**不进**消息中心,也不进待收列表:它是实时的业务载荷,
|
|
|
|
|
|
# 补历史没有意义(仪表盘要的是当下)。
|
|
|
|
|
|
return {"ok": True, "topic": topic, "mqtt": _mqtt_publish(topic, payload),
|
|
|
|
|
|
"stored": False}
|
|
|
|
|
|
|
|
|
|
|
|
message = {
|
|
|
|
|
|
"id": body.id or secrets.token_hex(8),
|
|
|
|
|
|
"title": body.title,
|
|
|
|
|
|
"body": body.body,
|
|
|
|
|
|
"level": body.level if body.level in ("info", "warn", "error") else "info",
|
|
|
|
|
|
"time": datetime.now().isoformat(timespec="seconds"),
|
|
|
|
|
|
"source": "example-server",
|
|
|
|
|
|
}
|
|
|
|
|
|
backlog = MESSAGES.setdefault(target, [])
|
|
|
|
|
|
backlog.append(message)
|
|
|
|
|
|
del backlog[:-MESSAGE_BACKLOG]
|
|
|
|
|
|
topic = user_message_topic(_mqtt_prefix(), _org(), target)
|
|
|
|
|
|
return {"ok": True, "topic": topic, "message": message, "stored": True,
|
|
|
|
|
|
"mqtt": _mqtt_publish(topic, json.dumps(message, ensure_ascii=False))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/broadcast")
|
|
|
|
|
|
def broadcast_message(body: PushBody, session: dict = Depends(require_auth)):
|
|
|
|
|
|
"""组织广播:发给整个组织(消息中心显示,带"广播"标记)。"""
|
|
|
|
|
|
topic = broadcast_message_topic(_mqtt_prefix(), _org())
|
|
|
|
|
|
message = {
|
|
|
|
|
|
"id": body.id or secrets.token_hex(8),
|
|
|
|
|
|
"title": body.title or "组织通知",
|
|
|
|
|
|
"body": body.body,
|
|
|
|
|
|
"level": body.level if body.level in ("info", "warn", "error") else "info",
|
|
|
|
|
|
"time": datetime.now().isoformat(timespec="seconds"),
|
|
|
|
|
|
"source": f"broadcast@{session['username']}",
|
|
|
|
|
|
}
|
|
|
|
|
|
return {"ok": True, "topic": topic, "message": message, "stored": False,
|
|
|
|
|
|
"mqtt": _mqtt_publish(topic, json.dumps(message, ensure_ascii=False))}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 17:15:48 +08:00
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
# 客户端自更新信息(可选)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/client/manifest.json")
|
|
|
|
|
|
def client_manifest():
|
|
|
|
|
|
"""客户端启动时查一次"有没有推荐的新版客户端"。
|
|
|
|
|
|
|
|
|
|
|
|
生产上这个文件通常是静态托管的(与插件同一套 ``<base_url>/…`` 布局,
|
|
|
|
|
|
内容 ``{"version", "url", "notes"}``);示例服务端用环境变量现场生成,
|
|
|
|
|
|
方便演示提示链路:
|
|
|
|
|
|
|
|
|
|
|
|
.. code-block:: bash
|
|
|
|
|
|
|
|
|
|
|
|
SVFS_CLIENT_VERSION=9.9.9 SVFS_CLIENT_URL=https://example.com/dl \\
|
|
|
|
|
|
uv run python examples/plugin_server.py
|
|
|
|
|
|
|
|
|
|
|
|
没设置时返回 404 = 客户端"没有更新信息"(静默跳过,不当错误)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
version = os.environ.get("SVFS_CLIENT_VERSION", "").strip()
|
|
|
|
|
|
if not version:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="未配置 SVFS_CLIENT_VERSION")
|
|
|
|
|
|
return {"version": version,
|
|
|
|
|
|
"url": os.environ.get("SVFS_CLIENT_URL", "").strip(),
|
|
|
|
|
|
"notes": os.environ.get("SVFS_CLIENT_NOTES", "").strip()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
# 插件下载:静态吐出 PLUGINS_DIR(务必注册在所有 API 路由之后)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/")
|
|
|
|
|
|
def index():
|
|
|
|
|
|
plugins = sorted(
|
|
|
|
|
|
entry.name for entry in PLUGINS_DIR.iterdir()
|
|
|
|
|
|
if (entry / "manifest.json").is_file()
|
|
|
|
|
|
)
|
|
|
|
|
|
return {"service": "svfs-plugin-server", "plugins": plugins}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.mount("/", StaticFiles(directory=str(PLUGINS_DIR)), name="plugins")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
import uvicorn
|
|
|
|
|
|
|
|
|
|
|
|
demo = os.environ.get("SVFS_USERS", "").strip()
|
|
|
|
|
|
if demo:
|
|
|
|
|
|
print(f"登录账号来自 SVFS_USERS 环境变量({len(demo.split(','))} 个)")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("登录账号:admin / admin123(可用 SVFS_USERS=\"user:pass,user2:pass2\" 自定义)")
|
|
|
|
|
|
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("SVFS_PORT", "8000")))
|