194 lines
6.1 KiB
Go
194 lines
6.1 KiB
Go
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/plugins/state"
|
|
"github.com/kandev/kandev/internal/plugins/webapp"
|
|
)
|
|
|
|
type webAppStateEntry struct {
|
|
Key string `json:"key"`
|
|
Value json.RawMessage `json:"value"`
|
|
Revision int64 `json:"revision"`
|
|
WriterKind string `json:"writer_kind"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
func (s *Service) handleWebAppState(ctx context.Context, w http.ResponseWriter, r *http.Request, binding webapp.CapabilityBinding, parts []string) {
|
|
if !webAppCapabilities(binding.Permissions).State {
|
|
writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied")
|
|
return
|
|
}
|
|
store := s.InstanceState()
|
|
if store == nil {
|
|
writeWebAppError(w, http.StatusNotImplemented, webAppRuntimeUnavailable)
|
|
return
|
|
}
|
|
switch {
|
|
case len(parts) == 2 && r.Method == http.MethodGet:
|
|
s.listWebAppState(ctx, w, r, store, binding.InstanceID)
|
|
case len(parts) == 3 && validWebAppStateKey(parts[2]) && r.Method == http.MethodGet:
|
|
s.getWebAppState(ctx, w, r, store, binding.InstanceID, parts[2])
|
|
case len(parts) == 3 && validWebAppStateKey(parts[2]) && r.Method == http.MethodPut:
|
|
s.setWebAppState(ctx, w, r, store, binding.InstanceID, parts[2])
|
|
case len(parts) == 3 && validWebAppStateKey(parts[2]) && r.Method == http.MethodDelete:
|
|
s.deleteWebAppState(ctx, w, r, store, binding.InstanceID, parts[2])
|
|
default:
|
|
writeWebAppError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
|
}
|
|
}
|
|
|
|
func validWebAppStateKey(key string) bool {
|
|
return validWebAppKey(key) && !strings.HasPrefix(key, "_")
|
|
}
|
|
|
|
func (s *Service) listWebAppState(ctx context.Context, w http.ResponseWriter, r *http.Request, store *state.InstanceStore, instanceID string) {
|
|
entries, err := store.List(ctx, instanceID)
|
|
if err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
items := make([]webAppStateEntry, len(entries))
|
|
for i, entry := range entries {
|
|
items[i] = webAppStateEntryFromStore(entry)
|
|
}
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppPage[webAppStateEntry]{Items: items, PageInfo: webAppPageInfo{}})
|
|
}
|
|
|
|
func (s *Service) getWebAppState(ctx context.Context, w http.ResponseWriter, r *http.Request, store *state.InstanceStore, instanceID, key string) {
|
|
entry, found, err := store.Get(ctx, instanceID, key)
|
|
if err != nil {
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
return
|
|
}
|
|
if !found {
|
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppStateEntryFromStore(entry))
|
|
}
|
|
|
|
func (s *Service) setWebAppState(ctx context.Context, w http.ResponseWriter, r *http.Request, store *state.InstanceStore, instanceID, key string) {
|
|
expected, ok := webAppIfMatch(r)
|
|
if !ok {
|
|
writeWebAppError(w, http.StatusPreconditionRequired, "plugin_state_precondition_required")
|
|
return
|
|
}
|
|
body, err := readWebAppBody(r)
|
|
if err != nil || !json.Valid(body) {
|
|
writeWebAppError(w, http.StatusBadRequest, "invalid_request")
|
|
return
|
|
}
|
|
entry, err := store.Set(ctx, instanceID, key, json.RawMessage(body), &expected, "browser")
|
|
if err != nil {
|
|
writeWebAppStateError(w, err)
|
|
return
|
|
}
|
|
writeWebAppJSON(w, r, http.StatusOK, webAppStateEntryFromStore(entry))
|
|
}
|
|
|
|
func (s *Service) deleteWebAppState(ctx context.Context, w http.ResponseWriter, r *http.Request, store *state.InstanceStore, instanceID, key string) {
|
|
expected, ok := webAppIfMatch(r)
|
|
if !ok {
|
|
writeWebAppError(w, http.StatusPreconditionRequired, "plugin_state_precondition_required")
|
|
return
|
|
}
|
|
revision, err := store.Delete(ctx, instanceID, key, &expected, "browser")
|
|
if err != nil {
|
|
writeWebAppStateError(w, err)
|
|
return
|
|
}
|
|
writeWebAppJSON(w, r, http.StatusOK, struct {
|
|
Revision int64 `json:"revision"`
|
|
}{Revision: revision})
|
|
}
|
|
|
|
func webAppStateEntryFromStore(entry state.InstanceStateEntry) webAppStateEntry {
|
|
return webAppStateEntry{
|
|
Key: entry.Key, Value: append(json.RawMessage(nil), entry.Value...),
|
|
Revision: entry.Revision, WriterKind: entry.WriterKind,
|
|
UpdatedAt: entry.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func webAppIfMatch(r *http.Request) (int64, bool) {
|
|
value := strings.TrimSpace(r.Header.Get("If-Match"))
|
|
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
|
|
value = value[1 : len(value)-1]
|
|
}
|
|
if value == "" || strings.ContainsAny(value, "\"*, ") {
|
|
return 0, false
|
|
}
|
|
revision, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil || revision < 0 {
|
|
return 0, false
|
|
}
|
|
return revision, true
|
|
}
|
|
|
|
func writeWebAppStateError(w http.ResponseWriter, err error) {
|
|
var conflict *state.ConflictError
|
|
if errors.As(err, &conflict) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusConflict)
|
|
_, _ = w.Write(mustMarshalWebAppStateConflict(conflict.CurrentRevision))
|
|
return
|
|
}
|
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
|
}
|
|
|
|
func mustMarshalWebAppStateConflict(revision int64) []byte {
|
|
body, err := json.Marshal(struct {
|
|
Error string `json:"error"`
|
|
CurrentRevision int64 `json:"current_revision"`
|
|
}{Error: "plugin_state_conflict", CurrentRevision: revision})
|
|
if err != nil {
|
|
return []byte(`{"error":"plugin_state_conflict"}`)
|
|
}
|
|
return body
|
|
}
|
|
|
|
func readWebAppBody(r *http.Request) ([]byte, error) {
|
|
if r.Body == nil {
|
|
return nil, errors.New("request body is required")
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, webAppRequestLimit+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) == 0 || len(body) > webAppRequestLimit {
|
|
return nil, errors.New("request body exceeds limit")
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func decodeWebAppJSON(r *http.Request, destination any) error {
|
|
body, err := readWebAppBody(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(destination); err != nil {
|
|
return err
|
|
}
|
|
var extra any
|
|
if err := decoder.Decode(&extra); err != io.EOF {
|
|
if err == nil {
|
|
return errors.New("multiple JSON values")
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|