Files
2026-09-05 18:44:43 +00:00

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
}