coverage~bigbes/sr-ht-spec3cb1c03dgraph/schema.resolvers.go

Coverage
58.6% 177/302 statements
Δ
Blob
7ddc50e
1 package graph
2
3 // This file will be automatically regenerated based on the schema, any resolver implementations
4 // will be copied through when generating and any unknown code will be moved to the end.
5 // Code generated by github.com/99designs/gqlgen version v0.17.36
6
7 import (
8 "context"
9 "database/sql"
10 "errors"
11 "fmt"
12 "net/url"
13 "strings"
14
15 sq "github.com/Masterminds/squirrel"
16 "github.com/lib/pq"
17 "sourcecraft.dev/bigbes/sr-ht-core/auth"
18 "sourcecraft.dev/bigbes/sr-ht-core/database"
19 coreerrors "sourcecraft.dev/bigbes/sr-ht-core/errors"
20 model1 "sourcecraft.dev/bigbes/sr-ht-core/model"
21 corewebhooks "sourcecraft.dev/bigbes/sr-ht-core/webhooks"
22 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
23 "sourcecraft.dev/bigbes/sr-ht-spec/core"
24 "sourcecraft.dev/bigbes/sr-ht-spec/doc"
25 "sourcecraft.dev/bigbes/sr-ht-spec/graph/api"
26 "sourcecraft.dev/bigbes/sr-ht-spec/graph/model"
27 "sourcecraft.dev/bigbes/sr-ht-spec/search"
28 "sourcecraft.dev/bigbes/sr-ht-spec/service"
29 )
30
31 // CreateUserWebhook is the resolver for the createUserWebhook field.
32 3 func (r *mutationResolver) CreateUserWebhook(ctx context.Context, config model.UserWebhookInput) (model.WebhookSubscription, error) {
33 3 // The owner gate is the whole ACL: spec is single-owner with no OAuth
34 3 // clients and no scopes, so there is no per-event grant to check the way
35 3 // pages.sr.ht does — either you are the owner and may manage every webhook,
36 3 // or you are refused here.
37 3 if err := webhookAuthorized(ctx); err != nil {
38 3 return nil, err
39 3 }
40
41 // The schema the root resolver holds, and not core-go's server context:
42 // /query is served on the anonymous router, where that context does not
43 // exist. It is the same schema either way — the daemon builds exactly one.
44 0 if err := corewebhooks.Validate(r.schema, config.Query); err != nil {
45 0 return nil, err
46 0 }
47
48 // The derived AuthContext is AUTH_INTERNAL (coreauth maps the owner to it),
49 // so NewAuthConfig returns an INTERNAL config: node_id set, the OAuth columns
50 // nil.
51 0 ac, err := corewebhooks.NewAuthConfig(ctx)
52 0 if err != nil {
53 0 return nil, err
54 0 }
55
56 0 if len(config.Events) == 0 {
57 0 return nil, fmt.Errorf("must specify at least one event")
58 0 }
59 0 events := make([]string, len(config.Events))
60 0 for i, ev := range config.Events {
61 0 events[i] = ev.String()
62 0 }
63
64 0 u, err := url.Parse(config.URL)
65 0 if err != nil {
66 0 return nil, err
67 0 } else if u.Host == "" {
68 0 return nil, fmt.Errorf("cannot use URL without host")
69 0 } else if u.Scheme != "http" && u.Scheme != "https" {
70 0 return nil, fmt.Errorf("cannot use non-HTTP or HTTPS URL")
71 0 }
72
73 0 var sub model.UserWebhookSubscription
74 0 if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
75 0 row := tx.QueryRowContext(ctx, `
76 0 INSERT INTO gql_user_wh_sub (
77 0 created, events, url, query,
78 0 auth_method,
79 0 token_hash, grants, client_id, expires,
80 0 node_id,
81 0 user_id
82 0 ) VALUES (
83 0 NOW() at time zone 'utc',
84 0 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
85 0 ) RETURNING id, url, query, events, user_id;`,
86 0 pq.Array(events), config.URL, config.Query,
87 0 ac.AuthMethod,
88 0 ac.TokenHash, ac.Grants, ac.ClientID, ac.Expires, // OAUTH2
89 0 ac.NodeID, // INTERNAL
90 0 auth.ForContext(ctx).UserID)
91 0
92 0 if err := row.Scan(&sub.ID, &sub.URL,
93 0 &sub.Query, pq.Array(&sub.Events), &sub.UserID); err != nil {
94 0 return err
95 0 }
96 0 return nil
97 0 }); err != nil {
98 0 return nil, err
99 0 }
100
101 0 return &sub, nil
102 }
103
104 // DeleteUserWebhook is the resolver for the deleteUserWebhook field.
105 3 func (r *mutationResolver) DeleteUserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
106 3 if err := webhookAuthorized(ctx); err != nil {
107 3 return nil, err
108 3 }
109
110 0 filter, err := corewebhooks.FilterWebhooks(ctx)
111 0 if err != nil {
112 0 return nil, err
113 0 }
114
115 0 var sub model.UserWebhookSubscription
116 0 if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
117 0 row := sq.Delete(`gql_user_wh_sub`).
118 0 PlaceholderFormat(sq.Dollar).
119 0 Where(sq.And{sq.Expr(`id = ?`, id), filter}).
120 0 Suffix(`RETURNING id, url, query, events, user_id`).
121 0 RunWith(tx).
122 0 QueryRowContext(ctx)
123 0 if err := row.Scan(&sub.ID, &sub.URL,
124 0 &sub.Query, pq.Array(&sub.Events), &sub.UserID); err != nil {
125 0 return err
126 0 }
127 0 return nil
128 0 }); err != nil {
129 0 if err == sql.ErrNoRows {
130 0 return nil, fmt.Errorf("no user webhook by ID %d found for this user", id)
131 0 }
132 0 return nil, err
133 }
134
135 0 return &sub, nil
136 }
137
138 // Spaces is the resolver for the spaces field.
139 4 func (r *projectResolver) Spaces(ctx context.Context, obj *model.Project) ([]*model.Space, error) {
140 4 ref := core.ProjectRef{Owner: obj.Owner, Name: obj.Name}
141 4 spaces, err := r.reader.ProjectSpaces(ctx, ref)
142 4 if err != nil {
143 0 return nil, err
144 0 }
145 4 return spaceModels(spaces), nil
146 }
147
148 // Spaces is the resolver for the spaces field.
149 4 func (r *queryResolver) Spaces(ctx context.Context) ([]*model.Space, error) {
150 4 spaces, err := r.reader.ListSpaces(ctx)
151 4 if err != nil {
152 0 return nil, err
153 0 }
154 4 return spaceModels(spaces), nil
155 }
156
157 // Space is the resolver for the space field.
158 4 func (r *queryResolver) Space(ctx context.Context, owner string, name string) (*model.Space, error) {
159 4 ref, err := spaceRef(owner, name)
160 4 if err != nil {
161 1 return nil, err
162 1 }
163 3 sp, err := r.reader.OpenSpace(ctx, ref)
164 3 if err != nil {
165 1 return nil, orNull(err)
166 1 }
167 2 return spaceModel(sp), nil
168 }
169
170 // Document is the resolver for the document field.
171 14 func (r *queryResolver) Document(ctx context.Context, space string, id *string, path *string, rev *string) (*model.Document, error) {
172 14 arc, bodies, resolved, pinned, err := r.archive(ctx, space, rev)
173 14 if err != nil {
174 6 return nil, orNull(err)
175 6 }
176 8 page, found, err := address(arc, deref(id), deref(path))
177 8 if err != nil || !found {
178 3 return nil, err
179 3 }
180 5 body, ok := bodies[page.Path]
181 5 if !ok {
182 0 // The archive is built from these very bodies, so a page without one is
183 0 // a broken invariant rather than a missing document. Reporting an empty
184 0 // markdown field would be indistinguishable from an empty document.
185 0 return nil, fmt.Errorf("document %s of %s at %s has no body", page.Path, arc.Space, resolved)
186 0 }
187 5 return documentModel(arc.Space, resolved, pinned, page, body), nil
188 }
189
190 // Documents is the resolver for the documents field.
191 1 func (r *queryResolver) Documents(ctx context.Context, space string, rev *string) ([]*model.Document, error) {
192 1 arc, bodies, resolved, pinned, err := r.archive(ctx, space, rev)
193 1 if err != nil {
194 0 return nil, err
195 0 }
196 1 pages := arc.All()
197 1 out := make([]*model.Document, 0, len(pages))
198 4 for _, page := range pages {
199 4 body, ok := bodies[page.Path]
200 4 if !ok {
201 0 return nil, fmt.Errorf("document %s of %s at %s has no body", page.Path, arc.Space, resolved)
202 0 }
203 4 out = append(out, documentModel(arc.Space, resolved, pinned, page, body))
204 }
205 1 return out, nil
206 }
207
208 // Search is the resolver for the search field.
209 6 func (r *queryResolver) Search(ctx context.Context, query string, spaces []string, limit *int, offset *int) (*model.SearchResults, error) {
210 6 q := search.Query{Text: strings.TrimSpace(query)}
211 6
212 6 // Filter polarity, which is the one thing here that is dangerous to get
213 6 // wrong — and the reason core.SpaceFilter exists rather than a slice.
214 6 // Absent means every space, which is the meta-project. A list means exactly
215 6 // those spaces, and a list that is present but empty therefore selects
216 6 // nothing at all: that is what an empty project's membership is, and a
217 6 // client passing one through must get no hits rather than the whole corpus.
218 6 // GraphQL distinguishes the two cases for free — null is not [] — so the
219 6 // argument's own shape carries the polarity that used to be a convention.
220 6 if spaces == nil {
221 3 q.Spaces = core.EverythingFilter()
222 3 } else {
223 3 refs := make([]core.SpaceRef, 0, len(spaces))
224 3 for _, s := range spaces {
225 1 ref, err := core.ParseSpaceRef(strings.TrimSpace(s))
226 1 if err != nil {
227 0 return nil, fmt.Errorf("spaces: %w", err)
228 0 }
229 1 refs = append(refs, ref)
230 }
231 3 q.Spaces = core.SpacesFilter(refs, nil)
232 }
233
234 6 if limit != nil {
235 2 if *limit < 0 {
236 1 return nil, fmt.Errorf("limit: %d is negative", *limit)
237 1 }
238 1 q.Limit = *limit
239 }
240 5 if offset != nil {
241 1 if *offset < 0 {
242 0 return nil, fmt.Errorf("offset: %d is negative", *offset)
243 0 }
244 1 q.Offset = *offset
245 }
246
247 5 res, err := r.searcher.Search(ctx, q)
248 5 if err != nil {
249 0 return nil, err
250 0 }
251 5 out := &model.SearchResults{
252 5 Total: int(res.Total),
253 5 Took: res.Took.String(),
254 5 Hits: make([]*model.SearchHit, 0, len(res.Hits)),
255 5 }
256 5 for _, h := range res.Hits {
257 3 out.Hits = append(out.Hits, &model.SearchHit{
258 3 Space: h.Space.String(),
259 3 ID: h.ID,
260 3 Rev: h.Rev,
261 3 Path: h.Path,
262 3 Anchor: h.Anchor,
263 3 Title: h.Title,
264 3 Section: h.Section,
265 3 Lang: string(h.Lang),
266 3 Score: h.Score,
267 3 Snippet: h.Snippet,
268 3 })
269 3 }
270 5 return out, nil
271 }
272
273 // Projects is the resolver for the projects field.
274 1 func (r *queryResolver) Projects(ctx context.Context) ([]*model.Project, error) {
275 1 projects, err := r.reader.ListProjects(ctx)
276 1 if err != nil {
277 0 return nil, err
278 0 }
279 1 out := make([]*model.Project, 0, len(projects))
280 2 for _, p := range projects {
281 2 out = append(out, projectModel(p.Ref))
282 2 }
283 1 return out, nil
284 }
285
286 // Project is the resolver for the project field.
287 5 func (r *queryResolver) Project(ctx context.Context, owner string, name string) (*model.Project, error) {
288 5 ref, err := projectRef(owner, name)
289 5 if err != nil {
290 0 return nil, err
291 0 }
292 // The meta-project is an address that resolves to a filter, not a row: it
293 // needs no storage, cannot be renamed or deleted, and adding a space to the
294 // service adds it to the meta-project by construction. So it is answered
295 // here rather than looked up — a lookup would report the one project that
296 // can never be missing as missing.
297 5 if ref.IsMeta() {
298 1 return projectModel(ref), nil
299 1 }
300 4 if _, err := r.reader.GetProject(ctx, ref); err != nil {
301 1 return nil, orNull(err)
302 1 }
303 3 return projectModel(ref), nil
304 }
305
306 // Proposals is the resolver for the proposals field.
307 3 func (r *queryResolver) Proposals(ctx context.Context, space string, state model.ProposalState) ([]*model.Proposal, error) {
308 3 ref, err := core.ParseSpaceRef(strings.TrimSpace(space))
309 3 if err != nil {
310 0 return nil, fmt.Errorf("space: %w", err)
311 0 }
312 // GraphQL has already validated the enum, so this cannot fail over a
313 // caller's spelling; it fails only if this schema and core disagree about
314 // the state machine, which is worth hearing about rather than defaulting
315 // past.
316 3 st, err := core.ParseProposalState(strings.ToLower(string(state)))
317 3 if err != nil {
318 0 return nil, err
319 0 }
320 3 if r.proposals == nil {
321 1 return nil, errors.New("proposals cannot be read: service/ exposes no proposal listing yet, " +
322 1 "and nothing above it may query the database directly (the write plane is Phase 3)")
323 1 }
324 2 proposals, err := r.proposals.ListProposals(ctx, ref, st)
325 2 if err != nil {
326 0 return nil, err
327 0 }
328 2 out := make([]*model.Proposal, 0, len(proposals))
329 2 for _, p := range proposals {
330 2 m, err := proposalModel(p)
331 2 if err != nil {
332 0 return nil, err
333 0 }
334 2 out = append(out, m)
335 }
336 2 return out, nil
337 }
338
339 // UserWebhooks is the resolver for the userWebhooks field.
340 3 func (r *queryResolver) UserWebhooks(ctx context.Context, cursor *model1.Cursor) (*model.WebhookSubscriptionCursor, error) {
341 3 if err := webhookAuthorized(ctx); err != nil {
342 3 return nil, err
343 3 }
344 0 if cursor == nil {
345 0 cursor = model1.NewCursor(nil)
346 0 }
347
348 0 filter, err := corewebhooks.FilterWebhooks(ctx)
349 0 if err != nil {
350 0 return nil, err
351 0 }
352
353 0 var subs []model.WebhookSubscription
354 0 if err := database.WithTx(ctx, &sql.TxOptions{
355 0 Isolation: 0,
356 0 ReadOnly: true,
357 0 }, func(tx *sql.Tx) error {
358 0 sub := (&model.UserWebhookSubscription{}).As(`sub`)
359 0 query := database.
360 0 Select(ctx, sub).
361 0 From(`gql_user_wh_sub sub`).
362 0 Where(filter)
363 0 subs, cursor = sub.QueryWithCursor(ctx, tx, query, cursor)
364 0 return nil
365 0 }); err != nil {
366 0 return nil, err
367 0 }
368
369 0 return &model.WebhookSubscriptionCursor{Results: subs, Cursor: cursor}, nil
370 }
371
372 // UserWebhook is the resolver for the userWebhook field.
373 3 func (r *queryResolver) UserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
374 3 if err := webhookAuthorized(ctx); err != nil {
375 3 return nil, err
376 3 }
377
378 0 filter, err := corewebhooks.FilterWebhooks(ctx)
379 0 if err != nil {
380 0 return nil, err
381 0 }
382
383 0 var sub model.UserWebhookSubscription
384 0 if err := database.WithTx(ctx, &sql.TxOptions{
385 0 Isolation: 0,
386 0 ReadOnly: true,
387 0 }, func(tx *sql.Tx) error {
388 0 row := database.
389 0 Select(ctx, &sub).
390 0 From(`gql_user_wh_sub`).
391 0 Where(sq.And{sq.Expr(`id = ?`, id), filter}).
392 0 RunWith(tx).
393 0 QueryRowContext(ctx)
394 0 if err := row.Scan(database.Scan(ctx, &sub)...); err != nil {
395 0 return err
396 0 }
397 0 return nil
398 0 }); err != nil {
399 0 if err == sql.ErrNoRows {
400 0 return nil, fmt.Errorf("no user webhook by ID %d found for this user", id)
401 0 }
402 0 return nil, err
403 }
404
405 0 return &sub, nil
406 }
407
408 // Webhook is the resolver for the webhook field.
409 1 func (r *queryResolver) Webhook(ctx context.Context) (model.WebhookPayload, error) {
410 1 raw, err := corewebhooks.Payload(ctx)
411 1 if err != nil {
412 1 return nil, err
413 1 }
414 0 payload, ok := raw.(model.WebhookPayload)
415 0 if !ok {
416 0 panic("Invalid webhook payload context")
417 }
418 0 return payload, nil
419 }
420
421 // ApprovedRev is the resolver for the approvedRev field.
422 1 func (r *spaceResolver) ApprovedRev(ctx context.Context, obj *model.Space) (string, error) {
423 1 // A field resolver rather than a struct field: ListSpaces deliberately does
424 1 // not open repositories, so resolving the approved head of every space in a
425 1 // listing would make the cheapest view in the service the most expensive
426 1 // one. Here it is paid for only by a query that asks for it.
427 1 sp, err := r.reader.OpenSpace(ctx, core.SpaceRef{Owner: obj.Owner, Name: obj.Name})
428 1 if err != nil {
429 0 return "", err
430 0 }
431 1 return r.reader.ResolveRev(ctx, sp, service.ApprovedRev)
432 }
433
434 // Deliveries is the resolver for the deliveries field.
435 0 func (r *userWebhookSubscriptionResolver) Deliveries(ctx context.Context, obj *model.UserWebhookSubscription, cursor *model1.Cursor) (*model.WebhookDeliveryCursor, error) {
436 0 if cursor == nil {
437 0 cursor = model1.NewCursor(nil)
438 0 }
439
440 0 var deliveries []*model.WebhookDelivery
441 0 if err := database.WithTx(ctx, &sql.TxOptions{
442 0 Isolation: 0,
443 0 ReadOnly: true,
444 0 }, func(tx *sql.Tx) error {
445 0 d := (&model.WebhookDelivery{}).
446 0 WithName(`user`).
447 0 As(`delivery`)
448 0 query := database.
449 0 Select(ctx, d).
450 0 From(`gql_user_wh_delivery delivery`).
451 0 Where(`delivery.subscription_id = ?`, obj.ID)
452 0 deliveries, cursor = d.QueryWithCursor(ctx, tx, query, cursor)
453 0 return nil
454 0 }); err != nil {
455 0 return nil, err
456 0 }
457
458 0 return &model.WebhookDeliveryCursor{Results: deliveries, Cursor: cursor}, nil
459 }
460
461 // Sample is the resolver for the sample field.
462 //
463 // It is the one field of this schema that /query cannot answer, and it says so
464 // rather than failing at a lower level with something a caller cannot read.
465 //
466 // The reason is core-go's, not this service's. corewebhooks.WebhookContext.Exec
467 // — which is what renders a sample, by running the subscriber's own stored query
468 // against a synthetic payload — reads the complexity limit off core-go's server
469 // context and panics when there is none. That context is installed by
470 // server.WithDefaultMiddleware on the *authenticated* router, and this endpoint
471 // is on the anonymous one because it authenticates with tokens.sr.ht working
472 // tokens rather than with meta's OAuth vocabulary. There is no exported way to
473 // put that value on a context; the alternatives are to fork Exec into this
474 // service or to let the panic surface as "internal system error", and a named
475 // refusal beats both.
476 //
477 // Delivery is unaffected: the webhook queue's own context comes from
478 // server.WithQueues, which does carry it, so a real delivery renders exactly as
479 // before.
480 0 func (r *userWebhookSubscriptionResolver) Sample(ctx context.Context, obj *model.UserWebhookSubscription, event model.WebhookEvent) (string, error) {
481 0 switch event {
482 case model.WebhookEventProposalOpened,
483 model.WebhookEventProposalMerged,
484 model.WebhookEventProposalRejected:
485 0 default:
486 0 return "", fmt.Errorf("unsupported event %s", event.String())
487 }
488 0 return "", fmt.Errorf(
489 0 "sample is not available on this endpoint: rendering one needs core-go's server " +
490 0 "context, which exists only on the authenticated router, and spec.sr.ht serves " +
491 0 "/query on the anonymous router so that a tokens.sr.ht working token is the " +
492 0 "credential; subscribe and trigger the event to see a real delivery")
493 }
494
495 // Subscription is the resolver for the subscription field.
496 0 func (r *webhookDeliveryResolver) Subscription(ctx context.Context, obj *model.WebhookDelivery) (model.WebhookSubscription, error) {
497 0 if obj.Name == "" {
498 0 panic("WebhookDelivery without name")
499 }
500
501 // XXX: This could use a loader but it's unlikely to be a bottleneck
502 0 var sub model.WebhookSubscription
503 0 if err := database.WithTx(ctx, &sql.TxOptions{
504 0 Isolation: 0,
505 0 ReadOnly: true,
506 0 }, func(tx *sql.Tx) error {
507 0 // XXX: This needs some work to generalize to other kinds of webhooks
508 0 var subscription interface {
509 0 model.WebhookSubscription
510 0 database.Model
511 0 } = nil
512 0 switch obj.Name {
513 0 case "user":
514 0 subscription = (&model.UserWebhookSubscription{}).As(`sub`)
515 0 default:
516 0 panic(fmt.Errorf("unknown webhook name %q", obj.Name))
517 }
518 // Note: No filter needed because, if we have access to the delivery,
519 // we also have access to the subscription.
520 0 row := database.
521 0 Select(ctx, subscription).
522 0 From(`gql_`+obj.Name+`_wh_sub sub`).
523 0 Where(`sub.id = ?`, obj.SubscriptionID).
524 0 RunWith(tx).
525 0 QueryRowContext(ctx)
526 0 if err := row.Scan(database.Scan(ctx, subscription)...); err != nil {
527 0 return err
528 0 }
529 0 sub = subscription
530 0 return nil
531 0 }); err != nil {
532 0 return nil, err
533 0 }
534 0 return sub, nil
535 }
536
537 // Mutation returns api.MutationResolver implementation.
538 6 func (r *Resolver) Mutation() api.MutationResolver { return &mutationResolver{r} }
539
540 // Project returns api.ProjectResolver implementation.
541 4 func (r *Resolver) Project() api.ProjectResolver { return &projectResolver{r} }
542
543 // Query returns api.QueryResolver implementation.
544 45 func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} }
545
546 // Space returns api.SpaceResolver implementation.
547 1 func (r *Resolver) Space() api.SpaceResolver { return &spaceResolver{r} }
548
549 // UserWebhookSubscription returns api.UserWebhookSubscriptionResolver implementation.
550 0 func (r *Resolver) UserWebhookSubscription() api.UserWebhookSubscriptionResolver {
551 0 return &userWebhookSubscriptionResolver{r}
552 0 }
553
554 // WebhookDelivery returns api.WebhookDeliveryResolver implementation.
555 0 func (r *Resolver) WebhookDelivery() api.WebhookDeliveryResolver { return &webhookDeliveryResolver{r} }
556
557 type mutationResolver struct{ *Resolver }
558 type projectResolver struct{ *Resolver }
559 type queryResolver struct{ *Resolver }
560 type spaceResolver struct{ *Resolver }
561 type userWebhookSubscriptionResolver struct{ *Resolver }
562 type webhookDeliveryResolver struct{ *Resolver }
563
564 // !!! WARNING !!!
565 // The code below was going to be deleted when updating resolvers. It has been copied here so you have
566 // one last chance to move it out of harms way if you want. There are two reasons this happens:
567 // - When renaming or deleting a resolver the old code will be put in here. You can safely delete
568 // it when you're done.
569 // - You have helper methods in this file. Move them out to keep these resolver files clean.
570 15 func (r *queryResolver) archive(ctx context.Context, space string, rev *string) (*doc.Archive, map[string][]byte, string, bool, error) {
571 15 ref, err := core.ParseSpaceRef(strings.TrimSpace(space))
572 15 if err != nil {
573 0 return nil, nil, "", false, fmt.Errorf("space: %w", err)
574 0 }
575 15 want := deref(rev)
576 15 if err := service.ValidateReadRev(want); err != nil {
577 5 return nil, nil, "", false, fmt.Errorf("rev: %w", err)
578 5 }
579 10 sp, err := r.reader.OpenSpace(ctx, ref)
580 10 if err != nil {
581 0 return nil, nil, "", false, err
582 0 }
583 10 arc, bodies, err := r.reader.Archive(ctx, sp, want)
584 10 if err != nil {
585 1 return nil, nil, "", false, err
586 1 }
587 9 return arc, bodies, arc.Rev, want != service.ApprovedRev, nil
588 }
589 8 func address(arc *doc.Archive, id, path string) (*doc.Page, bool, error) {
590 8 id = strings.TrimSpace(id)
591 8 path = strings.TrimSpace(path)
592 8
593 8 switch {
594 1 case id == "" && path == "":
595 1 return nil, false, errors.New("give either id or path: a document has to be addressed")
596 1 case id != "" && path != "":
597 1 return nil, false, fmt.Errorf("give either id or path, not both: %q and %q cannot both name the document wanted", id, path)
598 }
599
600 6 if path != "" {
601 1 if err := core.ValidateDocPath(path); err != nil {
602 0 return nil, false, fmt.Errorf("path: %w", err)
603 0 }
604 1 page, ok := arc.ByPath(path)
605 1 return page, ok, nil
606 }
607
608 5 if page, ok := arc.Page(id); ok {
609 4 return page, true, nil
610 4 }
611
612 // Nothing is addressed by that id. Saying only "no such document" would
613 // hide the one case a caller cannot otherwise diagnose: an id two documents
614 // claim resolves to neither of them, and the collision is invisible from
615 // outside the space.
616 1 var claimed []string
617 4 for _, p := range arc.All() {
618 4 if p.DocID == id {
619 2 claimed = append(claimed, p.Path)
620 2 }
621 }
622 1 if len(claimed) > 1 {
623 1 return nil, false, fmt.Errorf("id: %q is claimed by %d documents (%s) and resolves to none of them; address one of them by path instead",
624 1 id, len(claimed), strings.Join(claimed, ", "))
625 1 }
626 0 return nil, false, nil
627 }
628 13 func spaceModel(sp *service.Space) *model.Space {
629 13 return &model.Space{
630 13 Owner: sp.Ref.Owner,
631 13 Name: sp.Ref.Name,
632 13 Ref: sp.Ref.String(),
633 13 Created: sp.Created,
634 13 }
635 13 }
636 8 func spaceModels(spaces []*service.Space) []*model.Space {
637 8 out := make([]*model.Space, 0, len(spaces))
638 11 for _, sp := range spaces {
639 11 out = append(out, spaceModel(sp))
640 11 }
641 8 return out
642 }
643 6 func projectModel(ref core.ProjectRef) *model.Project {
644 6 return &model.Project{
645 6 Owner: ref.Owner,
646 6 Name: ref.Name,
647 6 Ref: ref.String(),
648 6 Meta: ref.IsMeta(),
649 6 }
650 6 }
651 9 func documentModel(space core.SpaceRef, rev string, pinned bool, page *doc.Page, body []byte) *model.Document {
652 9 front, _ := doc.ParseFront(body)
653 9 return &model.Document{
654 9 Space: space.String(),
655 9 ID: page.ID,
656 9 DocID: nilIfEmpty(page.DocID),
657 9 Path: page.Path,
658 9 Rev: rev,
659 9 Blob: page.Blob,
660 9 Pinned: pinned,
661 9 Title: page.Title,
662 9 Status: string(page.Status),
663 9 Section: page.Section,
664 9 Summary: page.Summary,
665 9 Type: front.Type,
666 9 Supersedes: front.Supersedes,
667 9 Tags: nonNil(page.Tags),
668 9 Owners: nonNil(front.Owners),
669 9 Markdown: string(body),
670 9 }
671 9 }
672 2 func proposalModel(p Proposal) (*model.Proposal, error) {
673 2 state, err := proposalState(p.State)
674 2 if err != nil {
675 0 return nil, err
676 0 }
677 2 m := &model.Proposal{
678 2 ID: p.ID,
679 2 Space: p.Space.String(),
680 2 Title: p.Title,
681 2 Rationale: p.Rationale,
682 2 BaseRev: p.BaseRev,
683 2 Branch: p.Branch,
684 2 State: state,
685 2 MergedRev: nilIfEmpty(p.MergedRev),
686 2 Agent: p.Agent,
687 2 AgentSession: p.AgentSession,
688 2 Created: p.Created,
689 2 Resolved: p.Resolved,
690 2 }
691 2 // Approval is empty until a proposal merges, and the difference between
692 2 // human and policy approval is one a reader must be able to see: reporting
693 2 // either value for an unmerged proposal would launder unreviewed agent
694 2 // output as blessed, or claim a review that did not happen.
695 2 if p.Approval != "" {
696 1 approval, err := proposalApproval(p.Approval)
697 1 if err != nil {
698 0 return nil, err
699 0 }
700 1 m.Approval = &approval
701 }
702 2 return m, nil
703 }
704 2 func proposalState(s core.ProposalState) (model.ProposalState, error) {
705 2 out := model.ProposalState(strings.ToUpper(string(s)))
706 2 if !out.IsValid() {
707 0 return "", fmt.Errorf("proposal state %q is not one of %v", s, model.AllProposalState)
708 0 }
709 2 return out, nil
710 }
711 1 func proposalApproval(a core.Approval) (model.Approval, error) {
712 1 out := model.Approval(strings.ToUpper(string(a)))
713 1 if !out.IsValid() {
714 0 return "", fmt.Errorf("approval %q is not one of %v", a, model.AllApproval)
715 0 }
716 1 return out, nil
717 }
718 4 func spaceRef(owner, name string) (core.SpaceRef, error) {
719 4 ref := core.SpaceRef{Owner: strings.TrimSpace(owner), Name: strings.TrimSpace(name)}
720 4 if err := core.ValidateOwner(ref.Owner); err != nil {
721 1 return core.SpaceRef{}, fmt.Errorf("owner: %w", err)
722 1 }
723 3 if err := core.ValidateSpaceName(ref.Name); err != nil {
724 0 return core.SpaceRef{}, fmt.Errorf("name: %w", err)
725 0 }
726 3 return ref, nil
727 }
728 5 func projectRef(owner, name string) (core.ProjectRef, error) {
729 5 ref := core.ProjectRef{Owner: strings.TrimSpace(owner), Name: strings.TrimSpace(name)}
730 5 if err := core.ValidateOwner(ref.Owner); err != nil {
731 0 return core.ProjectRef{}, fmt.Errorf("owner: %w", err)
732 0 }
733 5 if ref.IsMeta() {
734 1 return ref, nil
735 1 }
736 4 if err := core.ValidateProjectName(ref.Name); err != nil {
737 0 return core.ProjectRef{}, fmt.Errorf("name: %w", err)
738 0 }
739 4 return ref, nil
740 }
741
742 // webhookAuthorized permits only the instance owner to touch webhooks. It is the
743 // whole ACL of the webhook chapter of this schema: createUserWebhook,
744 // deleteUserWebhook, userWebhooks and userWebhook all call it, and the two field
745 // resolvers that carry webhook data (UserWebhookSubscription.deliveries,
746 // WebhookDelivery.subscription) are reachable only through an object one of
747 // those four returned.
748 //
749 // The owner and nobody else, and specifically not an agent. That is the
750 // pre-conversion behaviour preserved deliberately rather than caution: before
751 // /query moved to the anonymous router, core-go's auth.Middleware plus the
752 // daemon's ownerOnly wrapper refused every caller whose username was not
753 // [sr.ht] owner-name, so an agent could not reach any of these fields at all.
754 // The move opened the endpoint to tokens.sr.ht working tokens, and checking only
755 // AUTH_INTERNAL would have let a token carrying nothing but authn.ActionRead
756 // create and delete subscriptions, and read every subscription's URL, stored
757 // query and delivery bodies — a read grant buying a mutation, which is the one
758 // thing a grant vocabulary exists to prevent.
759 //
760 // The identity is therefore asked first, and asked of spec's own principal,
761 // because that is the only value left that still tells the owner from an agent:
762 // coreauth maps both to AUTH_INTERNAL on purpose, since INTERNAL is what
763 // core-go's NewAuthConfig and FilterWebhooks demand of anyone at all.
764 //
765 // Opening this to agents is a grant-vocabulary decision and not a code one. It
766 // needs a third grant beside authn.ActionRead and authn.ActionPropose, spelled
767 // once and minted by tokens.sr.ht; inventing that string here would produce a
768 // grant no existing token carries and no daemon has ever issued.
769 //
770 // The consequence today is that webhook management is unreachable over /query,
771 // because this endpoint accepts no credential that resolves to the owner: authn
772 // produces authn.KindOwner from the unified-login cookie alone, and this
773 // endpoint reads no cookie. The check is written against the right predicate
774 // regardless, so a credential plane that does yield the owner works the moment
775 // it exists instead of needing this rule rediscovered.
776 16 func webhookAuthorized(ctx context.Context) error {
777 16 if !authn.PrincipalFromContext(ctx).IsOwner() {
778 15 return accessDenied()
779 15 }
780 // The shape core-go's webhook engine demands of whoever got this far.
781 // NewAuthConfig refuses AUTH_COOKIE outright and panics on a method it does
782 // not recognise, and FilterWebhooks keys its subscription filter off this
783 // field; coreauth is what makes the owner INTERNAL. Asserting it here is
784 // what stops a change in that mapping from reaching those two as a panic.
785 1 if auth.ForContext(ctx).AuthMethod != auth.AUTH_INTERNAL {
786 0 return accessDenied()
787 0 }
788 1 return nil
789 }
790
791 // accessDenied builds a fresh ERR_ACCESS_DENIED, and the freshness is the whole
792 // point of the function.
793 //
794 // coreerrors.ErrAccessDenied is a package-level *gqlerror.Error — one object,
795 // shared by every caller in the process — and gqlgen writes the field path onto
796 // the error it is given (graphql/error.go: `gqlErr.Path = GetPath(ctx)`). Return
797 // the singleton and that assignment lands on the shared object, so the next
798 // refusal anywhere in the daemon carries the previous one's path: measured
799 // against a running daemon, a refused `deleteUserWebhook`, `userWebhooks` and
800 // `userWebhook` all reported `"path":["createUserWebhook"]`, the field some
801 // earlier request had asked for. It is a data race on a global as well as a
802 // wrong answer.
803 //
804 // The bug is core-go's and predates this endpoint, but it was latent while the
805 // daemon's ownerOnly middleware refused a non-owner before any resolver ran.
806 // Refusing agents here makes it the common path, so it is fixed rather than
807 // inherited. Same message and same code as the singleton; only the identity of
808 // the object differs.
809 15 func accessDenied() error {
810 15 return coreerrors.New(coreerrors.AccessDenied, "Access denied")
811 15 }
812 8 func orNull(err error) error {
813 8 if errors.Is(err, service.ErrNotFound) {
814 3 return nil
815 3 }
816 5 return err
817 }
818 31 func deref(s *string) string {
819 31 if s == nil {
820 16 return ""
821 16 }
822 15 return *s
823 }
824 11 func nilIfEmpty(s string) *string {
825 11 if s == "" {
826 3 return nil
827 3 }
828 8 return &s
829 }
830 18 func nonNil(s []string) []string {
831 18 if s == nil {
832 10 return []string{}
833 10 }
834 8 return s
835 }