441 lines
11 KiB
Go
441 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"rawmark/internal/i18n"
|
|
"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, i18n.New("invalid_plugin_manifest_a_valid_121d94")
|
|
}
|
|
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, i18n.New("plugin_directory_does_not_match_0eda90")
|
|
}
|
|
rev, err := dir.ReadFile(".rawmark-revision")
|
|
if err != nil {
|
|
return nil, info, err
|
|
}
|
|
if !pluginRevisionPattern.Match(rev) {
|
|
return nil, info, i18n.New("invalid_plugin_installation_information_3e7873")
|
|
}
|
|
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 = i18n.New("invalid_plugin_enabled_configuration_26d4bb")
|
|
}
|
|
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, message(w, "plugin_not_found_or_installation_f577c6"))
|
|
} 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, i18n.New("could_not_read_plugin_zip_922fd9")
|
|
}
|
|
if len(archive.File) > 128 {
|
|
return info, i18n.New("a_plugin_may_contain_at_41c105")
|
|
}
|
|
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, i18n.New("plugin_contains_invalid_paths_duplicate_6a1e05")
|
|
}
|
|
seen[name] = true
|
|
if file.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
if file.UncompressedSize64 > maxPluginExpanded || total+int64(file.UncompressedSize64) > maxPluginExpanded {
|
|
return info, i18n.New("expanded_plugin_exceeds_mib_e06d7b")
|
|
}
|
|
src, err := file.Open()
|
|
if err != nil {
|
|
return info, i18n.New("could_not_read_plugin_file_016a17", 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, i18n.New("expanded_plugin_exceeds_mib_e06d7b")
|
|
}
|
|
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, i18n.New("the_zip_root_must_contain_ee8588")
|
|
}
|
|
main, err := os.Stat(filepath.Join(destination, "main.js"))
|
|
if err != nil || !main.Mode().IsRegular() {
|
|
return info, i18n.New("main_js_must_be_a_e84ee8")
|
|
}
|
|
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, message(w, "plugin_directory_is_not_configured_5e0928"))
|
|
return
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxPluginZip+(64<<10))
|
|
if err := r.ParseMultipartForm(maxPluginZip); err != nil {
|
|
writeError(w, 400, message(w, "could_not_read_plugin_upload_5fd8bc"))
|
|
return
|
|
}
|
|
defer r.MultipartForm.RemoveAll()
|
|
file, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeError(w, 400, message(w, "choose_a_plugin_zip_file_7360c5"))
|
|
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, message(w, "plugin_zip_exceeds_mib_046e37"))
|
|
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, i18n.RenderError(err, responseLocale(w)))
|
|
return
|
|
}
|
|
target := filepath.Join(s.PluginsDir, info.ID)
|
|
if _, err := os.Lstat(target); err == nil {
|
|
writeError(w, 409, message(w, "a_plugin_with_this_id_1551cb"))
|
|
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, message(w, "enabled_must_be_specified_12873c"))
|
|
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, message(w, "plugin_resource_not_found_8e73f3"))
|
|
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, message(w, "plugin_resource_not_found_8e73f3"))
|
|
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, message(w, "data_must_be_provided_0dc1bc"))
|
|
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})
|
|
}
|