diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go index f92de21..9b34e7b 100644 --- a/cmd/rawmark/main.go +++ b/cmd/rawmark/main.go @@ -96,6 +96,7 @@ func run(addr string) error { srv := api.New(v, am, st, hub, idx) srv.ThemesDir = filepath.Join(dataDir, "themes") + srv.PluginsDir = filepath.Join(dataDir, "plugins") srv.OnSetup = func() { os.Remove(codeFile) } go srv.FollowMoves(ctx) httpServer := &http.Server{ diff --git a/internal/api/api.go b/internal/api/api.go index 3f697b9..27cc0ef 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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}, diff --git a/internal/api/api_test.go b/internal/api/api_test.go index bf9f6ea..4a214e3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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()} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 6d7f3d6..999d446 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -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" + } + } } } } diff --git a/internal/api/plugins.go b/internal/api/plugins.go new file mode 100644 index 0000000..9ecbabf --- /dev/null +++ b/internal/api/plugins.go @@ -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}) +} diff --git a/internal/api/plugins_test.go b/internal/api/plugins_test.go new file mode 100644 index 0000000..e920720 --- /dev/null +++ b/internal/api/plugins_test.go @@ -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) + } + } +} diff --git a/web/src/app/PluginMount.vue b/web/src/app/PluginMount.vue new file mode 100644 index 0000000..8b99352 --- /dev/null +++ b/web/src/app/PluginMount.vue @@ -0,0 +1,16 @@ + + + diff --git a/web/src/app/PluginsPanel.vue b/web/src/app/PluginsPanel.vue new file mode 100644 index 0000000..c9e3863 --- /dev/null +++ b/web/src/app/PluginsPanel.vue @@ -0,0 +1,68 @@ + + + diff --git a/web/src/app/SettingsDialog.vue b/web/src/app/SettingsDialog.vue index 6d6102a..42100f3 100644 --- a/web/src/app/SettingsDialog.vue +++ b/web/src/app/SettingsDialog.vue @@ -4,6 +4,7 @@ import { api, ApiError } from './api' import { allCommands, formatKeys, runCommand } from './commands' import Icon, { type IconName } from './Icon.vue' import AppearanceSettings from './AppearanceSettings.vue' +import PluginsPanel from './PluginsPanel.vue' import { CONTENT_WIDTHS, FONT_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings' import SharePanel from './SharePanel.vue' import TokensPanel from './TokensPanel.vue' @@ -12,6 +13,7 @@ import { toast, ws, type SettingsSection } from './workspace' const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [ { id: 'appearance', title: '外观', icon: 'palette' }, + { id: 'plugins', title: '插件', icon: 'command' }, { id: 'account', title: '账户', icon: 'user' }, { id: 'shortcuts', title: '快捷键', icon: 'command' }, { id: 'shares', title: '分享链接', icon: 'link' }, @@ -106,7 +108,8 @@ async function changePassword() {
-