okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
8e3e173
main
1package daemon 2 3import ( 4"bytes" 5"context" 6"encoding/json" 7"errors" 8"fmt" 9"maps" 10"net/http" 11"net/url" 12"path" 13"slices" 14"strings" 15"time" 16 17"albedo/cli/internal/daemon/protocol" 18 19"github.com/google/jsonschema-go/jsonschema" 20) 21 22type PageActionRequest struct { 23Action PageAction 24Row * PageRow 25Form map [string ]json.RawMessage 26Session * Session 27} 28 29func LoadPage (ctx context.Context ,conn * Connection ,session ,command string ) (* PageDocument ,error ) { 30snapshot ,err := GetSession (ctx ,conn ,session ) 31if err != nil { 32return nil ,err 33 } 34if command == "/ttl" || command == "/requests" { 35return loadPlatformInspector (ctx ,conn ,snapshot ,command ) 36 } 37if command == "/links" { 38command = "/link" 39 } 40commands ,err := ListSessionCommands (ctx ,conn ,session ) 41if err != nil { 42return nil ,err 43 } 44for _ ,declared := range commands { 45if declared .Name != command || declared .Delivery != "read" { 46continue 47 } 48payload ,err := executeBoundOperation (ctx ,conn ,declared .Operation ,nil ,map [string ]json.RawMessage {},& snapshot ) 49if err != nil { 50return nil ,err 51 } 52doc ,err := readResultPage (payload ,declared .Name ,declared .Description ) 53if err == nil { 54doc .Session = & snapshot 55doc .read = & pageRead {Name :declared .Name ,Description :declared .Description ,Operation :declared .Operation ,Form :map [string ]json.RawMessage {}} 56 } 57return doc ,err 58 } 59return nil ,fmt .Errorf ("no declared page for %s" ,command ) 60} 61 62func RefreshPage (ctx context.Context ,conn * Connection ,session ,command string ,previous * PageDocument ) (* PageDocument ,error ) { 63if previous == nil || previous .read == nil { 64return LoadPage (ctx ,conn ,session ,command ) 65 } 66snapshot ,err := GetSession (ctx ,conn ,session ) 67if err != nil { 68return nil ,err 69 } 70read := previous .read 71payload ,err := executeBoundOperation (ctx ,conn ,read .Operation ,read .Row ,read .Form ,& snapshot ) 72if err != nil { 73return nil ,err 74 } 75doc ,err := readResultPage (payload ,read .Name ,read .Description ) 76if err == nil { 77doc .Session ,doc .read = & snapshot ,read 78 } 79return doc ,err 80} 81func ExecutePageAction (ctx context.Context ,conn * Connection ,request PageActionRequest ) (CommandResult ,error ) { 82form := request .Form 83if form == nil { 84form = map [string ]json.RawMessage {} 85 } 86payload ,err := executeBoundOperation (ctx ,conn ,request .Action .Operation ,request .Row ,form ,request .Session ) 87if err != nil { 88return CommandResult {},err 89 } 90if request .Action .Operation .Method == http .MethodGet { 91doc ,err := readResultPage (payload ,request .Action .Label ,"" ) 92if err != nil { 93return CommandResult {},err 94 } 95doc .Session = request .Session 96var row * PageRow 97if request .Row != nil { 98captured := * request .Row 99row = & captured 100 } 101doc .read = & pageRead {Name :request .Action .Label ,Operation :request .Action .Operation ,Row :row ,Form :maps .Clone (form )} 102return CommandResult {Result :payload ,Page :doc },nil 103 } 104return extensionResult (payload ),nil 105} 106func executeBoundOperation (ctx context.Context ,conn * Connection ,declared protocol.ActionOperation ,row * PageRow ,form map [string ]json.RawMessage ,session * Session ) ([]byte ,error ) { 107if ctx == nil { 108ctx = context .Background () 109 } 110ctx ,cancel := context .WithTimeout (ctx ,20 * time .Second ) 111defer cancel () 112template := declared .PathTemplate 113if ! strings .HasPrefix (template ,"/" )|| strings .HasPrefix (template ,"//" )|| strings .ContainsAny (template ,"?#\\" )|| path .Clean (template )!= template { 114return nil ,errors .New ("unsafe declared action path" ) 115 } 116var rowData ,sessionData json.RawMessage 117if row != nil { 118rowData ,_ = json .Marshal (row .wire ) 119 } 120if session != nil { 121sessionData ,_ = json .Marshal (session .wire ) 122 } 123formData ,_ := json .Marshal (form ) 124resolve := func (raw protocol.Binding ) (json.RawMessage ,error ) { 125return resolveBinding (json .RawMessage (raw ),rowData ,formData ,sessionData ) 126 } 127route := template 128for name ,binding := range declared .Path { 129resolved ,err := resolve (binding ) 130if err != nil { 131return nil ,err 132 } 133text ,err := bindingText (resolved ) 134if err != nil { 135return nil ,err 136 } 137if text == "" || text == "." || text == ".." || strings .ContainsAny (text ,"/\\" ) { 138return nil ,errors .New ("unsafe path binding" ) 139 } 140route = strings .ReplaceAll (route ,"{" + name + "}" ,url .PathEscape (text )) 141 } 142if strings .ContainsAny (route ,"{}" ) { 143return nil ,errors .New ("unresolved action path" ) 144 } 145method := declared .Method 146if ! strings .HasPrefix (route ,"/extensions/" )&& method != http .MethodGet { 147return nil ,errors .New ("a page cannot edit platform credentials, defaults, or daemon lifecycle" ) 148 } 149if method != http .MethodGet && method != http .MethodPut && method != http .MethodPost && method != http .MethodPatch && method != http .MethodDelete { 150return nil ,errors .New ("unsupported declared action method" ) 151 } 152statuses := []int {http .StatusOK } 153if method != http .MethodGet { 154statuses = append (statuses ,http .StatusCreated ) 155 } 156if declared .SuccessStatus != nil { 157status := int (* declared .SuccessStatus ) 158if status != http .StatusOK && status != http .StatusCreated || method == http .MethodGet && status != http .StatusOK { 159return nil ,errors .New ("unsupported declared success status" ) 160 } 161statuses = []int {status } 162 } 163query := url.Values {} 164for name ,binding := range declared .Query { 165resolved ,err := resolve (binding ) 166if err != nil { 167if _ ,optional := errors.AsType [* missingFormBindingError ](err );optional { 168continue 169 } 170return nil ,err 171 } 172text ,err := bindingText (resolved ) 173if err != nil { 174return nil ,err 175 } 176query .Set (name ,text ) 177 } 178headers := http.Header {} 179for name ,binding := range declared .Headers { 180if ! strings .EqualFold (name ,"If-Match" ) { 181return nil ,errors .New ("declared actions may only bind If-Match" ) 182 } 183resolved ,err := resolve (binding ) 184if err != nil { 185return nil ,err 186 } 187text ,err := bindingText (resolved ) 188if err != nil { 189return nil ,err 190 } 191if text == "" { 192return nil ,errors .New ("the displayed resource has no validator" ) 193 } 194headers .Set ("If-Match" ,text ) 195 } 196var body any 197if len (declared .Body )> 0 { 198if err := validateBodyBindings (declared .Body );err != nil { 199return nil ,err 200 } 201fields := map [string ]any {} 202for pointer ,binding := range declared .Body { 203resolved ,err := resolve (binding ) 204if err != nil { 205if _ ,optional := errors.AsType [* missingFormBindingError ](err );optional { 206continue 207 } 208return nil ,err 209 } 210field ,err := dynamicValue (resolved ) 211if err != nil { 212return nil ,err 213 } 214if err := setBodyPointer (fields ,pointer ,field );err != nil { 215return nil ,err 216 } 217 } 218body = fields 219 } 220if method == http .MethodPatch { 221headers .Set ("Content-Type" ,"application/merge-patch+json" ) 222 } 223if encoded := query .Encode ();encoded != "" { 224route += "?" + encoded 225 } 226policy := noRecovery 227if method == http .MethodGet { 228policy = readRecovery 229 } 230target := extensionAcknowledgmentTarget (declared .OperationID ) 231var resultSchema * jsonschema.Resolved 232if target == nil && method != http .MethodGet && len (declared .ResultSchema )> 0 && string (bytes .TrimSpace (declared .ResultSchema ))!= "{}" { 233var schema jsonschema.Schema 234if err := json .Unmarshal (declared .ResultSchema ,& schema );err != nil { 235return nil ,fmt .Errorf ("invalid declared result schema: %w" ,err ) 236 } 237var err error 238resultSchema ,err = schema .Resolve (& jsonschema.ResolveOptions {Loader :func (* url.URL ) (* jsonschema.Schema ,error ) { 239return nil ,errors .New ("result schemas cannot retrieve external references" ) 240 }}) 241if err != nil { 242return nil ,fmt .Errorf ("invalid declared result schema: %w" ,err ) 243 } 244 } 245op := operation {Name :"invoke " + declared .OperationID ,Method :method ,Path :route ,Headers :headers ,Body :body ,Policy :policy } 246if method == http .MethodGet { 247return requestBytes (ctx ,conn ,op ,responseLimits {successStatuses :statuses ,bodyBytes :1048576 ,errorBytes :65536 }) 248 } 249var payload []byte 250err := executeMutation (ctx ,conn ,op ,statuses ,func (data []byte ,_ int )error { 251if resultSchema != nil { 252result ,err := dynamicValue (data ) 253if err != nil { 254return err 255 } 256if err := resultSchema .Validate (result );err != nil { 257return fmt .Errorf ("declared result schema: %w" ,err ) 258 } 259 } 260if target != nil { 261if err := decodeRequired (data ,target );err != nil { 262return err 263 } 264 }else if ! json .Valid (data ) { 265return fieldError ("extension operation result" ) 266 } 267payload = slices .Clone (data ) 268return nil 269 }) 270return payload ,err 271} 272func extensionResult (data []byte )CommandResult { 273result := CommandResult {Result :data ,Message :"Saved." } 274var envelope struct { 275Notification * protocol.Notification `json:"notification"` 276Notifications []protocol.TargetNotification `json:"notifications"` 277Page * protocol.PageDescriptor `json:"page"` 278Truncated bool `json:"truncated"` 279NotificationCount int64 `json:"notification_count"` 280 } 281if json .Unmarshal (data ,& envelope )== nil { 282if envelope .Notification != nil && envelope .Notification .State == "failed" { 283result .Message = "Saved; notification failed: " + value (envelope .Notification .Detail ) 284 } 285for _ ,target := range envelope .Notifications { 286if target .Notification .State == "failed" { 287if result .Message == "Saved." { 288result .Message = "Saved; notifications failed:" 289 } 290result .Message += " " + target .SessionID + ": " + value (target .Notification .Detail ) 291 } 292 } 293if envelope .Truncated { 294result .Message += fmt .Sprintf (" Notification results show %d of %d sessions." ,len (envelope .Notifications ),envelope .NotificationCount ) 295 } 296if envelope .Page != nil { 297result .Page ,_ = pageValue (* envelope .Page ) 298 } 299 } 300return result 301} 302 303// Built-in operations have typed contracts independent of their page layout. 304// Additional extensions describe their own result schema in the catalog. 305func extensionAcknowledgmentTarget (operationID string )any { 306switch operationID { 307case "mergeLinkGroups" ,"unlinkWorkspace" : 308return new (protocol.LinkChange ) 309case "deleteWork" ,"deletePaperclips" ,"deleteSchedule" : 310return new (protocol.ExtensionDeletion ) 311case "createWork" ,"patchWork" : 312return new (protocol.WorkChange ) 313case "createPaperclips" ,"patchPaperclips" : 314return new (protocol.PaperclipChange ) 315case "createSchedule" ,"patchSchedule" : 316return new (protocol.ScheduleChange ) 317default : 318return nil 319 } 320}