okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: replace the generic /quota page with a screen of latest limits, bars and per-limit trails8e3e173

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