Add managed plugins with lifecycle cleanup and editor extensions
This commit is contained in:
1 parent
9fd7e2a948
commit
8ce4a11b33
25 files changed
+1731
-73
No files matched your search
+10
-1
@@ -40,7 +40,9 @@ type Server struct {
|
||||
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
|
||||
OnSetup func()
|
||||
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
|
||||
ThemesDir string
|
||||
ThemesDir string
|
||||
PluginsDir string
|
||||
pluginsMu sync.Mutex
|
||||
}
|
||||
|
||||
func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server {
|
||||
@@ -91,6 +93,13 @@ func (s *Server) routes() []route {
|
||||
|
||||
{"GET /api/v1/settings", authed, s.getSettings},
|
||||
{"PATCH /api/v1/settings", sessionOnly, s.patchSettings},
|
||||
{"GET /api/v1/plugins", sessionOnly, s.listPlugins},
|
||||
{"POST /api/v1/plugins", sessionOnly, s.installPlugin},
|
||||
{"PATCH /api/v1/plugins/{id}", sessionOnly, s.togglePlugin},
|
||||
{"DELETE /api/v1/plugins/{id}", sessionOnly, s.uninstallPlugin},
|
||||
{"GET /api/v1/plugins/{id}/assets/{revision}/{path...}", sessionOnly, s.pluginAsset},
|
||||
{"GET /api/v1/plugins/{id}/data", sessionOnly, s.getPluginData},
|
||||
{"PUT /api/v1/plugins/{id}/data", sessionOnly, s.putPluginData},
|
||||
{"GET /api/v1/themes", authed, s.listThemes},
|
||||
{"GET /api/v1/themes/{id}", authed, s.getTheme},
|
||||
{"GET /api/v1/shares", sessionOnly, s.listShares},
|
||||
|
||||
@@ -57,6 +57,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
t.Cleanup(func() { idx.Close() })
|
||||
server := New(v, am, st, events.NewHub(), idx)
|
||||
server.ThemesDir = filepath.Join(dir, "themes")
|
||||
server.PluginsDir = filepath.Join(dir, "plugins")
|
||||
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
||||
t.Cleanup(srv.Close)
|
||||
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, admin: newClient()}
|
||||
|
||||
@@ -1668,6 +1668,384 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/plugins": {
|
||||
"get": {
|
||||
"summary": "列出已安装插件及启用状态",
|
||||
"tags": [
|
||||
"插件"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Plugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "需要登录会话"
|
||||
},
|
||||
"400": {
|
||||
"description": "请求或插件包无效"
|
||||
},
|
||||
"403": {
|
||||
"description": "不接受 API 令牌"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "安装 ZIP 插件;安装后默认停用,同 id 拒绝覆盖",
|
||||
"tags": [
|
||||
"插件"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Plugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "需要登录会话"
|
||||
},
|
||||
"400": {
|
||||
"description": "请求或插件包无效"
|
||||
},
|
||||
"403": {
|
||||
"description": "不接受 API 令牌"
|
||||
},
|
||||
"409": {
|
||||
"description": "同名插件已安装"
|
||||
},
|
||||
"413": {
|
||||
"description": "ZIP 超过 10 MiB"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/plugins/{id}": {
|
||||
"patch": {
|
||||
"summary": "修改全设备插件启用偏好",
|
||||
"tags": [
|
||||
"插件"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Plugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "需要登录会话"
|
||||
},
|
||||
"400": {
|
||||
"description": "请求或插件包无效"
|
||||
},
|
||||
"403": {
|
||||
"description": "不接受 API 令牌"
|
||||
},
|
||||
"404": {
|
||||
"description": "插件或指定版本的资源不存在"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"summary": "停用并卸载插件包,保留插件私有数据",
|
||||
"tags": [
|
||||
"插件"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功"
|
||||
},
|
||||
"401": {
|
||||
"description": "需要登录会话"
|
||||
},
|
||||
"400": {
|
||||
"description": "请求或插件包无效"
|
||||
},
|
||||
"403": {
|
||||
"description": "不接受 API 令牌"
|
||||
},
|
||||
"404": {
|
||||
"description": "插件或指定版本的资源不存在"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/plugins/{id}/data": {
|
||||
"get": {
|
||||
"summary": "读取插件私有 JSON,无数据时为 null",
|
||||
"tags": [
|
||||
"插件"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "需要登录会话"
|
||||
},
|
||||
"400": {
|
||||
"description": "请求或插件包无效"
|
||||
},
|
||||
"403": {
|
||||
"description": "不接受 API 令牌"
|
||||
},
|
||||
"404": {
|
||||
"description": "插件或指定版本的资源不存在"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"summary": "替换插件私有 JSON,最多 1 MiB",
|
||||
"tags": [
|
||||
"插件"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "需要登录会话"
|
||||
},
|
||||
"400": {
|
||||
"description": "请求或插件包无效"
|
||||
},
|
||||
"403": {
|
||||
"description": "不接受 API 令牌"
|
||||
},
|
||||
"404": {
|
||||
"description": "插件或指定版本的资源不存在"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/plugins/{id}/assets/{revision}/{path}": {
|
||||
"get": {
|
||||
"summary": "鉴权读取指定安装版本的包内资源",
|
||||
"tags": [
|
||||
"插件"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功"
|
||||
},
|
||||
"401": {
|
||||
"description": "需要登录会话"
|
||||
},
|
||||
"400": {
|
||||
"description": "请求或插件包无效"
|
||||
},
|
||||
"403": {
|
||||
"description": "不接受 API 令牌"
|
||||
},
|
||||
"404": {
|
||||
"description": "插件或指定版本的资源不存在"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "revision",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -1849,6 +2227,38 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Plugin": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"version",
|
||||
"apiVersion",
|
||||
"revision",
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"revision": {
|
||||
"type": "string"
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"rawmark/internal/events"
|
||||
)
|
||||
|
||||
const maxPluginZip = 10 << 20
|
||||
const maxPluginExpanded = 20 << 20
|
||||
const pluginEnabledKey = "plugins.enabled"
|
||||
|
||||
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
|
||||
var pluginRevisionPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
|
||||
type pluginManifest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
APIVersion int `json:"apiVersion"`
|
||||
}
|
||||
|
||||
type pluginInfo struct {
|
||||
pluginManifest
|
||||
Revision string `json:"revision"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func validPluginID(id string) bool {
|
||||
return pluginIDPattern.MatchString(id) && !strings.HasPrefix(id, "rawmark-")
|
||||
}
|
||||
|
||||
func parsePluginManifest(data []byte) (pluginManifest, error) {
|
||||
var m pluginManifest
|
||||
if len(data) > 16<<10 || json.Unmarshal(data, &m) != nil || !validPluginID(m.ID) || strings.TrimSpace(m.Name) == "" || strings.TrimSpace(m.Version) == "" || m.APIVersion != 1 {
|
||||
return m, fmt.Errorf("插件 manifest 无效,必须包含合法的 id、name、version 和 apiVersion: 1")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Server) openPlugin(id string) (*os.Root, pluginInfo, error) {
|
||||
var info pluginInfo
|
||||
if !validPluginID(id) || s.PluginsDir == "" {
|
||||
return nil, info, fs.ErrNotExist
|
||||
}
|
||||
root, err := os.OpenRoot(s.PluginsDir)
|
||||
if err != nil {
|
||||
return nil, info, err
|
||||
}
|
||||
defer root.Close()
|
||||
dir, err := root.OpenRoot(id)
|
||||
if err != nil {
|
||||
return nil, info, err
|
||||
}
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
dir.Close()
|
||||
}
|
||||
}()
|
||||
data, err := dir.ReadFile("manifest.json")
|
||||
if err != nil {
|
||||
return nil, info, err
|
||||
}
|
||||
m, err := parsePluginManifest(data)
|
||||
if err != nil {
|
||||
return nil, info, err
|
||||
}
|
||||
if m.ID != id {
|
||||
return nil, info, fmt.Errorf("插件目录与 manifest id 不一致")
|
||||
}
|
||||
rev, err := dir.ReadFile(".rawmark-revision")
|
||||
if err != nil {
|
||||
return nil, info, err
|
||||
}
|
||||
if !pluginRevisionPattern.Match(rev) {
|
||||
return nil, info, fmt.Errorf("插件安装信息无效")
|
||||
}
|
||||
info.pluginManifest, info.Revision = m, string(rev)
|
||||
ok = true
|
||||
return dir, info, nil
|
||||
}
|
||||
|
||||
func (s *Server) enabledPlugins() (map[string]bool, error) {
|
||||
value, ok, err := s.store.Setting(pluginEnabledKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabled := map[string]bool{}
|
||||
if ok {
|
||||
err = json.Unmarshal([]byte(value), &enabled)
|
||||
}
|
||||
if enabled == nil && err == nil {
|
||||
err = fmt.Errorf("插件启用配置无效")
|
||||
}
|
||||
return enabled, err
|
||||
}
|
||||
|
||||
func (s *Server) setPluginEnabled(id string, enabled bool) error {
|
||||
values, err := s.enabledPlugins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled {
|
||||
values[id] = true
|
||||
} else {
|
||||
delete(values, id)
|
||||
}
|
||||
data, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.SetSetting(pluginEnabledKey, string(data))
|
||||
}
|
||||
|
||||
func (s *Server) pluginError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
writeError(w, 404, "插件不存在或安装不完整")
|
||||
} else {
|
||||
internalError(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
s.pluginsMu.Lock()
|
||||
defer s.pluginsMu.Unlock()
|
||||
installed := []pluginInfo{}
|
||||
enabled, err := s.enabledPlugins()
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if s.PluginsDir != "" {
|
||||
entries, err := os.ReadDir(s.PluginsDir)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || !validPluginID(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
dir, info, err := s.openPlugin(entry.Name())
|
||||
if err != nil {
|
||||
s.pluginError(w, r, err)
|
||||
return
|
||||
}
|
||||
dir.Close()
|
||||
info.Enabled = enabled[info.ID]
|
||||
installed = append(installed, info)
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"plugins": installed})
|
||||
}
|
||||
|
||||
func unpackPlugin(data []byte, destination string) (pluginInfo, error) {
|
||||
var info pluginInfo
|
||||
archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return info, fmt.Errorf("无法读取插件 ZIP")
|
||||
}
|
||||
if len(archive.File) > 128 {
|
||||
return info, fmt.Errorf("插件最多包含 128 个文件或目录")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var total int64
|
||||
for _, file := range archive.File {
|
||||
name := strings.TrimSuffix(file.Name, "/")
|
||||
if !fs.ValidPath(name) || strings.ContainsAny(name, "\\:\x00") || strings.HasPrefix(name, ".") || name != path.Clean(name) || seen[name] || (!file.FileInfo().IsDir() && !file.Mode().IsRegular()) {
|
||||
return info, fmt.Errorf("插件包含非法路径、重复路径或特殊文件")
|
||||
}
|
||||
seen[name] = true
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if file.UncompressedSize64 > maxPluginExpanded || total+int64(file.UncompressedSize64) > maxPluginExpanded {
|
||||
return info, fmt.Errorf("插件解压后超过 20 MiB")
|
||||
}
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return info, fmt.Errorf("无法读取插件文件:%w", err)
|
||||
}
|
||||
content, readErr := io.ReadAll(io.LimitReader(src, maxPluginExpanded-total+1))
|
||||
closeErr := src.Close()
|
||||
if readErr != nil {
|
||||
return info, readErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return info, closeErr
|
||||
}
|
||||
total += int64(len(content))
|
||||
if total > maxPluginExpanded {
|
||||
return info, fmt.Errorf("插件解压后超过 20 MiB")
|
||||
}
|
||||
if name == "manifest.json" {
|
||||
info.pluginManifest, err = parsePluginManifest(content)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
}
|
||||
full := filepath.Join(destination, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
|
||||
return info, err
|
||||
}
|
||||
if err := os.WriteFile(full, content, 0o600); err != nil {
|
||||
return info, err
|
||||
}
|
||||
}
|
||||
if info.ID == "" || !seen["main.js"] {
|
||||
return info, fmt.Errorf("ZIP 根目录必须包含 manifest.json 和 main.js")
|
||||
}
|
||||
main, err := os.Stat(filepath.Join(destination, "main.js"))
|
||||
if err != nil || !main.Mode().IsRegular() {
|
||||
return info, fmt.Errorf("main.js 必须为普通文件")
|
||||
}
|
||||
hash := sha256.Sum256(data)
|
||||
info.Revision = hex.EncodeToString(hash[:])
|
||||
err = os.WriteFile(filepath.Join(destination, ".rawmark-revision"), []byte(info.Revision), 0o600)
|
||||
return info, err
|
||||
}
|
||||
|
||||
func (s *Server) installPlugin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.PluginsDir == "" {
|
||||
writeError(w, 503, "插件目录未配置")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxPluginZip+(64<<10))
|
||||
if err := r.ParseMultipartForm(maxPluginZip); err != nil {
|
||||
writeError(w, 400, "无法读取插件上传,请使用不超过 10 MiB 的 ZIP")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, 400, "请选择插件 ZIP 文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxPluginZip+1))
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if len(data) > maxPluginZip {
|
||||
writeError(w, 413, "插件 ZIP 超过 10 MiB")
|
||||
return
|
||||
}
|
||||
s.pluginsMu.Lock()
|
||||
defer s.pluginsMu.Unlock()
|
||||
if err := os.MkdirAll(s.PluginsDir, 0o700); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
stage, err := os.MkdirTemp(s.PluginsDir, ".install-")
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
info, err := unpackPlugin(data, stage)
|
||||
if err != nil {
|
||||
writeError(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
target := filepath.Join(s.PluginsDir, info.ID)
|
||||
if _, err := os.Lstat(target); err == nil {
|
||||
writeError(w, 409, "同名插件已安装,请先卸载")
|
||||
return
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.setPluginEnabled(info.ID, false); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(stage, target); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.hub.Broadcast(events.Event{Type: "plugins"})
|
||||
writeJSON(w, 201, info)
|
||||
}
|
||||
|
||||
func (s *Server) togglePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.Enabled == nil {
|
||||
writeError(w, 400, "必须指定 enabled")
|
||||
return
|
||||
}
|
||||
s.pluginsMu.Lock()
|
||||
defer s.pluginsMu.Unlock()
|
||||
dir, info, err := s.openPlugin(r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.pluginError(w, r, err)
|
||||
return
|
||||
}
|
||||
dir.Close()
|
||||
if err := s.setPluginEnabled(info.ID, *body.Enabled); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
info.Enabled = *body.Enabled
|
||||
s.hub.Broadcast(events.Event{Type: "settings"})
|
||||
writeJSON(w, 200, info)
|
||||
}
|
||||
|
||||
func (s *Server) uninstallPlugin(w http.ResponseWriter, r *http.Request) {
|
||||
s.pluginsMu.Lock()
|
||||
defer s.pluginsMu.Unlock()
|
||||
id := r.PathValue("id")
|
||||
dir, _, err := s.openPlugin(id)
|
||||
if err != nil {
|
||||
s.pluginError(w, r, err)
|
||||
return
|
||||
}
|
||||
dir.Close()
|
||||
if err := s.setPluginEnabled(id, false); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(s.PluginsDir, id)); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.hub.Broadcast(events.Event{Type: "plugins"})
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) pluginAsset(w http.ResponseWriter, r *http.Request) {
|
||||
s.pluginsMu.Lock()
|
||||
defer s.pluginsMu.Unlock()
|
||||
dir, info, err := s.openPlugin(r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.pluginError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer dir.Close()
|
||||
name := r.PathValue("path")
|
||||
if r.PathValue("revision") != info.Revision || !fs.ValidPath(name) || strings.ContainsAny(name, "\\:") || strings.HasPrefix(name, ".") {
|
||||
writeError(w, 404, "插件资源不存在")
|
||||
return
|
||||
}
|
||||
file, err := dir.Open(name)
|
||||
if err != nil {
|
||||
s.pluginError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
writeError(w, 404, "插件资源不存在")
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
ext := path.Ext(name)
|
||||
if ext == ".js" || ext == ".mjs" {
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
} else {
|
||||
ct := mime.TypeByExtension(ext)
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'")
|
||||
}
|
||||
http.ServeContent(w, r, name, stat.ModTime(), file)
|
||||
}
|
||||
|
||||
func (s *Server) getPluginData(w http.ResponseWriter, r *http.Request) {
|
||||
s.pluginsMu.Lock()
|
||||
defer s.pluginsMu.Unlock()
|
||||
id := r.PathValue("id")
|
||||
dir, _, err := s.openPlugin(id)
|
||||
if err != nil {
|
||||
s.pluginError(w, r, err)
|
||||
return
|
||||
}
|
||||
dir.Close()
|
||||
value, ok, err := s.store.Setting("plugin.data." + id)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
value = "null"
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"data": json.RawMessage(value)})
|
||||
}
|
||||
|
||||
func (s *Server) putPluginData(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if len(body.Data) == 0 {
|
||||
writeError(w, 400, "必须提供 data")
|
||||
return
|
||||
}
|
||||
s.pluginsMu.Lock()
|
||||
defer s.pluginsMu.Unlock()
|
||||
id := r.PathValue("id")
|
||||
dir, _, err := s.openPlugin(id)
|
||||
if err != nil {
|
||||
s.pluginError(w, r, err)
|
||||
return
|
||||
}
|
||||
dir.Close()
|
||||
if err := s.store.SetSetting("plugin.data."+id, string(body.Data)); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"data": body.Data})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func pluginUpload(t *testing.T, f *fixture, client *http.Client, files []struct {
|
||||
name, content string
|
||||
mode os.FileMode
|
||||
}) *http.Response {
|
||||
t.Helper()
|
||||
var archive bytes.Buffer
|
||||
z := zip.NewWriter(&archive)
|
||||
for _, file := range files {
|
||||
h := &zip.FileHeader{Name: file.name, Method: zip.Deflate}
|
||||
if file.mode != 0 {
|
||||
h.SetMode(file.mode)
|
||||
}
|
||||
w, err := z.CreateHeader(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(w, file.content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := z.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var body bytes.Buffer
|
||||
m := multipart.NewWriter(&body)
|
||||
w, err := m.CreateFormFile("file", "plugin.zip")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(archive.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f.do(client, "POST", "/api/v1/plugins", body.String(), map[string]string{"Content-Type": m.FormDataContentType()})
|
||||
}
|
||||
|
||||
func TestPluginInstallBoundariesAndData(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
files := []struct {
|
||||
name, content string
|
||||
mode os.FileMode
|
||||
}{
|
||||
{"manifest.json", `{"id":"sample","name":"示例","version":"1","apiVersion":1}`, 0},
|
||||
{"main.js", `import './lib.js'; export default function () {}`, 0},
|
||||
{"lib.js", `export const value = 1`, 0},
|
||||
}
|
||||
guest := newClient()
|
||||
if res := pluginUpload(t, f, guest, files); res.StatusCode != 401 {
|
||||
t.Fatalf("匿名安装得到 %d", res.StatusCode)
|
||||
}
|
||||
created := pluginUpload(t, f, f.admin, files)
|
||||
if created.StatusCode != 201 {
|
||||
t.Fatalf("安装失败:%d", created.StatusCode)
|
||||
}
|
||||
info := decode[pluginInfo](t, created)
|
||||
if info.Enabled || len(info.Revision) != 64 {
|
||||
t.Fatal("安装默认状态无效")
|
||||
}
|
||||
if res := pluginUpload(t, f, f.admin, files); res.StatusCode != 409 {
|
||||
t.Fatalf("重复安装得到 %d", res.StatusCode)
|
||||
}
|
||||
base := "/api/v1/plugins/sample"
|
||||
asset := base + "/assets/" + info.Revision + "/main.js"
|
||||
res := f.do(f.admin, "GET", asset, "", nil)
|
||||
content, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode != 200 || !strings.Contains(string(content), "import") || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Fatal("模块资源读取失败")
|
||||
}
|
||||
if code := f.status(guest, "GET", asset, "", nil); code != 401 {
|
||||
t.Fatalf("匿名资源得到 %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "GET", strings.Replace(asset, info.Revision, strings.Repeat("0", 64), 1), "", nil); code != 404 {
|
||||
t.Fatalf("过期资源得到 %d", code)
|
||||
}
|
||||
toggle := decode[pluginInfo](t, f.do(f.admin, "PATCH", base, `{"enabled":true}`, nil))
|
||||
if !toggle.Enabled {
|
||||
t.Fatal("插件未启用")
|
||||
}
|
||||
if code := f.status(f.admin, "PUT", base+"/data", `{"data":{"counter":2}}`, nil); code != 200 {
|
||||
t.Fatalf("保存数据得到 %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "DELETE", base, "", nil); code != 200 {
|
||||
t.Fatalf("卸载得到 %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "GET", asset, "", nil); code != 404 {
|
||||
t.Fatalf("卸载后资源得到 %d", code)
|
||||
}
|
||||
if res := pluginUpload(t, f, f.admin, files); res.StatusCode != 201 {
|
||||
t.Fatal("重新安装失败")
|
||||
}
|
||||
data := decode[struct{ Data map[string]int }](t, f.do(f.admin, "GET", base+"/data", "", nil))
|
||||
if data.Data["counter"] != 2 {
|
||||
t.Fatal("卸载丢失了插件数据")
|
||||
}
|
||||
for _, bad := range []struct {
|
||||
name, content string
|
||||
mode os.FileMode
|
||||
}{
|
||||
{"../escape.js", "x", 0}, {"/absolute.js", "x", 0}, {"dir\\escape.js", "x", 0}, {"main.js", "重复", 0}, {"link.js", "main.js", os.ModeSymlink | 0o777},
|
||||
} {
|
||||
if res := pluginUpload(t, f, f.admin, append(files, bad)); res.StatusCode != 400 {
|
||||
t.Fatalf("非法包 %q 得到 %d", bad.name, res.StatusCode)
|
||||
}
|
||||
}
|
||||
large := append(files, struct {
|
||||
name, content string
|
||||
mode os.FileMode
|
||||
}{"large.js", strings.Repeat("x", maxPluginExpanded), 0})
|
||||
if res := pluginUpload(t, f, f.admin, large); res.StatusCode != 400 {
|
||||
t.Fatalf("解压超限得到 %d", res.StatusCode)
|
||||
}
|
||||
// 令牌即使有写权限,也不能植入或读取插件代码及私有配置。
|
||||
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"测试","scope":"write"}`, nil)).Token
|
||||
for _, target := range []string{"/api/v1/plugins", base + "/data", asset} {
|
||||
if code := f.status(guest, "GET", target, "", map[string]string{"Authorization": "Bearer " + token}); code != 403 {
|
||||
t.Fatalf("令牌访问插件得到 %d", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user