Files
VoCat/internal/server/extensions_api.go
T

162 lines
5.2 KiB
Go

package server
import (
"context"
"errors"
"io"
"net/http"
"os"
"strings"
"time"
)
const maxPluginUploadBytes int64 = 64 << 20
func (s *Server) routeExtensionAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
if cleanPath == "extensions" {
if s.extensions == nil {
writeError(w, http.StatusServiceUnavailable, "extensions_unavailable", "plugin manager is unavailable")
return true
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"data": s.extensions.List()})
default:
w.Header().Set("Allow", "GET")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
return true
}
if cleanPath == "extensions/install-url" {
if s.extensions == nil {
writeError(w, http.StatusServiceUnavailable, "extensions_unavailable", "plugin manager is unavailable")
return true
}
if !requireMethod(w, r, http.MethodPost) {
return true
}
var request struct {
URL string `json:"url"`
SHA256 string `json:"sha256"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
ctx, cancel := contextWithTimeout(r, 60*time.Second)
defer cancel()
plugin, err := s.extensions.InstallURL(ctx, request.URL, request.SHA256)
if err != nil {
writeError(w, http.StatusBadRequest, "plugin_install_failed", err.Error())
return true
}
s.recordAudit(r.Context(), "admin", "plugin.install_url", "plugin", plugin.ID, "success", request.URL)
writeJSON(w, http.StatusCreated, map[string]any{"data": plugin})
return true
}
if cleanPath == "extensions/upload" {
if s.extensions == nil {
writeError(w, http.StatusServiceUnavailable, "extensions_unavailable", "plugin manager is unavailable")
return true
}
if !requireMethod(w, r, http.MethodPost) {
return true
}
r.Body = http.MaxBytesReader(nil, r.Body, maxPluginUploadBytes+(1<<20))
if err := r.ParseMultipartForm(maxPluginUploadBytes); err != nil {
writeError(w, http.StatusBadRequest, "invalid_plugin_upload", "plugin upload must be multipart/form-data and no larger than 64 MiB")
return true
}
file, _, err := r.FormFile("package")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_plugin_upload", "multipart field package is required")
return true
}
defer file.Close()
plugin, err := s.extensions.Install(io.LimitReader(file, maxPluginUploadBytes+1), r.FormValue("sha256"))
if err != nil {
writeError(w, http.StatusBadRequest, "plugin_install_failed", err.Error())
return true
}
s.recordAudit(r.Context(), "admin", "plugin.upload", "plugin", plugin.ID, "success", "upload")
writeJSON(w, http.StatusCreated, map[string]any{"data": plugin})
return true
}
segments := splitAPIPath(cleanPath)
if len(segments) < 2 || segments[0] != "extensions" {
return false
}
if s.extensions == nil {
writeError(w, http.StatusServiceUnavailable, "extensions_unavailable", "plugin manager is unavailable")
return true
}
id := segments[1]
if len(segments) >= 3 && segments[2] == "backend" {
s.extensions.ProxyBackend(w, r, id)
return true
}
if len(segments) != 2 {
writeError(w, http.StatusNotFound, "not_found", "plugin endpoint not found")
return true
}
switch r.Method {
case http.MethodPut:
var request struct {
Enabled bool `json:"enabled"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
plugin, err := s.extensions.SetEnabled(id, request.Enabled)
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "plugin_not_found", "plugin not found")
return true
}
if err != nil {
writeError(w, http.StatusInternalServerError, "plugin_state_failed", err.Error())
return true
}
writeJSON(w, http.StatusOK, map[string]any{"data": plugin})
case http.MethodDelete:
if err := s.extensions.Uninstall(id); errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "plugin_not_found", "plugin not found")
} else if err != nil {
writeError(w, http.StatusInternalServerError, "plugin_uninstall_failed", err.Error())
} else {
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]bool{"uninstalled": true}})
}
default:
w.Header().Set("Allow", "PUT, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
return true
}
func (s *Server) handlePluginAsset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAuthenticated(w, r) {
return
}
if s.extensions == nil {
http.NotFound(w, r)
return
}
path := strings.TrimPrefix(r.URL.Path, "/plugin-assets/")
parts := strings.SplitN(path, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
http.NotFound(w, r)
return
}
s.extensions.ServeAsset(w, r, parts[0], parts[1])
}
func contextWithTimeout(r *http.Request, timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(r.Context(), timeout)
}