275 lines
10 KiB
Python
275 lines
10 KiB
Python
#!/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 os
|
|||
|
|
import secrets
|
|||
|
|
import sqlite3
|
|||
|
|
import tempfile
|
|||
|
|
from datetime import datetime
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
#: 插件包根:每个 <插件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, "username": body.username,
|
|||
|
|
"login_time": login_time}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.get("/api/me")
|
|||
|
|
def me(session: dict = Depends(require_auth)):
|
|||
|
|
"""客户端启动时校验记住的 token 是否仍然有效。"""
|
|||
|
|
return {"ok": True, **session}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@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"],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
# 客户端自更新信息(可选)
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
@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")))
|