Files
svfs/examples/plugin_server.py
T

461 lines
18 KiB
Python
Raw Normal View History

#!/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>
"""
import json
import os
import secrets
import sqlite3
import tempfile
import threading
from datetime import datetime
from pathlib import Path
from typing import Optional
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
from svfs_client.topics import (
broadcast_message_topic,
user_message_topic,
user_plugin_topic,
)
#: 插件包根:每个 <插件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}
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
@app.get("/api/me")
def me(session: dict = Depends(require_auth)):
"""客户端启动时校验记住的 token 是否仍然有效。
这里也要下发组织与 broker:静默校验通过后就**不再弹登录窗**,是客户端
拿到推送配置的另一条主路径。
"""
return {"ok": True, **_session_extra(session["username"],
session.get("login_time", ""))}
@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"],
}
# ----------------------------------------------------------------------
# 消息:服务端主动推给用户(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))}
# ----------------------------------------------------------------------
# 客户端自更新信息(可选)
# ----------------------------------------------------------------------
@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")))