package graph // This file will be automatically regenerated based on the schema, any resolver implementations // will be copied through when generating and any unknown code will be moved to the end. // Code generated by github.com/99designs/gqlgen version v0.17.36 import ( "context" "database/sql" "errors" "fmt" "net/url" "strings" "time" sq "github.com/Masterminds/squirrel" "github.com/google/uuid" "github.com/lib/pq" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-core/database" coreerrors "sourcecraft.dev/bigbes/sr-ht-core/errors" model1 "sourcecraft.dev/bigbes/sr-ht-core/model" "sourcecraft.dev/bigbes/sr-ht-core/server" corewebhooks "sourcecraft.dev/bigbes/sr-ht-core/webhooks" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/doc" "sourcecraft.dev/bigbes/sr-ht-spec/graph/api" "sourcecraft.dev/bigbes/sr-ht-spec/graph/model" "sourcecraft.dev/bigbes/sr-ht-spec/search" "sourcecraft.dev/bigbes/sr-ht-spec/service" ) // CreateUserWebhook is the resolver for the createUserWebhook field. func (r *mutationResolver) CreateUserWebhook(ctx context.Context, config model.UserWebhookInput) (model.WebhookSubscription, error) { // The owner gate is the whole ACL: spec is single-owner with no OAuth // clients and no scopes, so there is no per-event grant to check the way // pages.sr.ht does — either you are the owner and may manage every webhook, // or you are refused here. if !authn.PrincipalFromContext(ctx).IsOwner() { return nil, coreerrors.ErrAccessDenied } schema := server.ForContext(ctx).Schema if err := corewebhooks.Validate(schema, config.Query); err != nil { return nil, err } // The derived AuthContext is AUTH_INTERNAL (coreauth maps the owner to it), // so NewAuthConfig returns an INTERNAL config: node_id set, the OAuth columns // nil. ac, err := corewebhooks.NewAuthConfig(ctx) if err != nil { return nil, err } if len(config.Events) == 0 { return nil, fmt.Errorf("must specify at least one event") } events := make([]string, len(config.Events)) for i, ev := range config.Events { events[i] = ev.String() } u, err := url.Parse(config.URL) if err != nil { return nil, err } else if u.Host == "" { return nil, fmt.Errorf("cannot use URL without host") } else if u.Scheme != "http" && u.Scheme != "https" { return nil, fmt.Errorf("cannot use non-HTTP or HTTPS URL") } var sub model.UserWebhookSubscription if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error { row := tx.QueryRowContext(ctx, ` INSERT INTO gql_user_wh_sub ( created, events, url, query, auth_method, token_hash, grants, client_id, expires, node_id, user_id ) VALUES ( NOW() at time zone 'utc', $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) RETURNING id, url, query, events, user_id;`, pq.Array(events), config.URL, config.Query, ac.AuthMethod, ac.TokenHash, ac.Grants, ac.ClientID, ac.Expires, // OAUTH2 ac.NodeID, // INTERNAL auth.ForContext(ctx).UserID) if err := row.Scan(&sub.ID, &sub.URL, &sub.Query, pq.Array(&sub.Events), &sub.UserID); err != nil { return err } return nil }); err != nil { return nil, err } return &sub, nil } // DeleteUserWebhook is the resolver for the deleteUserWebhook field. func (r *mutationResolver) DeleteUserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) { if !authn.PrincipalFromContext(ctx).IsOwner() { return nil, coreerrors.ErrAccessDenied } filter, err := corewebhooks.FilterWebhooks(ctx) if err != nil { return nil, err } var sub model.UserWebhookSubscription if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error { row := sq.Delete(`gql_user_wh_sub`). PlaceholderFormat(sq.Dollar). Where(sq.And{sq.Expr(`id = ?`, id), filter}). Suffix(`RETURNING id, url, query, events, user_id`). RunWith(tx). QueryRowContext(ctx) if err := row.Scan(&sub.ID, &sub.URL, &sub.Query, pq.Array(&sub.Events), &sub.UserID); err != nil { return err } return nil }); err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("no user webhook by ID %d found for this user", id) } return nil, err } return &sub, nil } // Spaces is the resolver for the spaces field. func (r *projectResolver) Spaces(ctx context.Context, obj *model.Project) ([]*model.Space, error) { ref := core.ProjectRef{Owner: obj.Owner, Name: obj.Name} spaces, err := r.reader.ProjectSpaces(ctx, ref) if err != nil { return nil, err } return spaceModels(spaces), nil } // Spaces is the resolver for the spaces field. func (r *queryResolver) Spaces(ctx context.Context) ([]*model.Space, error) { spaces, err := r.reader.ListSpaces(ctx) if err != nil { return nil, err } return spaceModels(spaces), nil } // Space is the resolver for the space field. func (r *queryResolver) Space(ctx context.Context, owner string, name string) (*model.Space, error) { ref, err := spaceRef(owner, name) if err != nil { return nil, err } sp, err := r.reader.OpenSpace(ctx, ref) if err != nil { return nil, orNull(err) } return spaceModel(sp), nil } // Document is the resolver for the document field. func (r *queryResolver) Document(ctx context.Context, space string, id *string, path *string, rev *string) (*model.Document, error) { arc, bodies, resolved, pinned, err := r.archive(ctx, space, rev) if err != nil { return nil, orNull(err) } page, found, err := address(arc, deref(id), deref(path)) if err != nil || !found { return nil, err } body, ok := bodies[page.Path] if !ok { // The archive is built from these very bodies, so a page without one is // a broken invariant rather than a missing document. Reporting an empty // markdown field would be indistinguishable from an empty document. return nil, fmt.Errorf("document %s of %s at %s has no body", page.Path, arc.Space, resolved) } return documentModel(arc.Space, resolved, pinned, page, body), nil } // Documents is the resolver for the documents field. func (r *queryResolver) Documents(ctx context.Context, space string, rev *string) ([]*model.Document, error) { arc, bodies, resolved, pinned, err := r.archive(ctx, space, rev) if err != nil { return nil, err } pages := arc.All() out := make([]*model.Document, 0, len(pages)) for _, page := range pages { body, ok := bodies[page.Path] if !ok { return nil, fmt.Errorf("document %s of %s at %s has no body", page.Path, arc.Space, resolved) } out = append(out, documentModel(arc.Space, resolved, pinned, page, body)) } return out, nil } // Search is the resolver for the search field. func (r *queryResolver) Search(ctx context.Context, query string, spaces []string, limit *int, offset *int) (*model.SearchResults, error) { q := search.Query{Text: strings.TrimSpace(query)} // Filter polarity, which is the one thing here that is dangerous to get // wrong — and the reason core.SpaceFilter exists rather than a slice. // Absent means every space, which is the meta-project. A list means exactly // those spaces, and a list that is present but empty therefore selects // nothing at all: that is what an empty project's membership is, and a // client passing one through must get no hits rather than the whole corpus. // GraphQL distinguishes the two cases for free — null is not [] — so the // argument's own shape carries the polarity that used to be a convention. if spaces == nil { q.Spaces = core.EverythingFilter() } else { refs := make([]core.SpaceRef, 0, len(spaces)) for _, s := range spaces { ref, err := core.ParseSpaceRef(strings.TrimSpace(s)) if err != nil { return nil, fmt.Errorf("spaces: %w", err) } refs = append(refs, ref) } q.Spaces = core.SpacesFilter(refs, nil) } if limit != nil { if *limit < 0 { return nil, fmt.Errorf("limit: %d is negative", *limit) } q.Limit = *limit } if offset != nil { if *offset < 0 { return nil, fmt.Errorf("offset: %d is negative", *offset) } q.Offset = *offset } res, err := r.searcher.Search(ctx, q) if err != nil { return nil, err } out := &model.SearchResults{ Total: int(res.Total), Took: res.Took.String(), Hits: make([]*model.SearchHit, 0, len(res.Hits)), } for _, h := range res.Hits { out.Hits = append(out.Hits, &model.SearchHit{ Space: h.Space.String(), ID: h.ID, Rev: h.Rev, Path: h.Path, Anchor: h.Anchor, Title: h.Title, Section: h.Section, Lang: string(h.Lang), Score: h.Score, Snippet: h.Snippet, }) } return out, nil } // Projects is the resolver for the projects field. func (r *queryResolver) Projects(ctx context.Context) ([]*model.Project, error) { projects, err := r.reader.ListProjects(ctx) if err != nil { return nil, err } out := make([]*model.Project, 0, len(projects)) for _, p := range projects { out = append(out, projectModel(p.Ref)) } return out, nil } // Project is the resolver for the project field. func (r *queryResolver) Project(ctx context.Context, owner string, name string) (*model.Project, error) { ref, err := projectRef(owner, name) if err != nil { return nil, err } // The meta-project is an address that resolves to a filter, not a row: it // needs no storage, cannot be renamed or deleted, and adding a space to the // service adds it to the meta-project by construction. So it is answered // here rather than looked up — a lookup would report the one project that // can never be missing as missing. if ref.IsMeta() { return projectModel(ref), nil } if _, err := r.reader.GetProject(ctx, ref); err != nil { return nil, orNull(err) } return projectModel(ref), nil } // Proposals is the resolver for the proposals field. func (r *queryResolver) Proposals(ctx context.Context, space string, state model.ProposalState) ([]*model.Proposal, error) { ref, err := core.ParseSpaceRef(strings.TrimSpace(space)) if err != nil { return nil, fmt.Errorf("space: %w", err) } // GraphQL has already validated the enum, so this cannot fail over a // caller's spelling; it fails only if this schema and core disagree about // the state machine, which is worth hearing about rather than defaulting // past. st, err := core.ParseProposalState(strings.ToLower(string(state))) if err != nil { return nil, err } if r.proposals == nil { return nil, errors.New("proposals cannot be read: service/ exposes no proposal listing yet, " + "and nothing above it may query the database directly (the write plane is Phase 3)") } proposals, err := r.proposals.ListProposals(ctx, ref, st) if err != nil { return nil, err } out := make([]*model.Proposal, 0, len(proposals)) for _, p := range proposals { m, err := proposalModel(p) if err != nil { return nil, err } out = append(out, m) } return out, nil } // UserWebhooks is the resolver for the userWebhooks field. func (r *queryResolver) UserWebhooks(ctx context.Context, cursor *model1.Cursor) (*model.WebhookSubscriptionCursor, error) { if !authn.PrincipalFromContext(ctx).IsOwner() { return nil, coreerrors.ErrAccessDenied } if cursor == nil { cursor = model1.NewCursor(nil) } filter, err := corewebhooks.FilterWebhooks(ctx) if err != nil { return nil, err } var subs []model.WebhookSubscription if err := database.WithTx(ctx, &sql.TxOptions{ Isolation: 0, ReadOnly: true, }, func(tx *sql.Tx) error { sub := (&model.UserWebhookSubscription{}).As(`sub`) query := database. Select(ctx, sub). From(`gql_user_wh_sub sub`). Where(filter) subs, cursor = sub.QueryWithCursor(ctx, tx, query, cursor) return nil }); err != nil { return nil, err } return &model.WebhookSubscriptionCursor{Results: subs, Cursor: cursor}, nil } // UserWebhook is the resolver for the userWebhook field. func (r *queryResolver) UserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) { if !authn.PrincipalFromContext(ctx).IsOwner() { return nil, coreerrors.ErrAccessDenied } filter, err := corewebhooks.FilterWebhooks(ctx) if err != nil { return nil, err } var sub model.UserWebhookSubscription if err := database.WithTx(ctx, &sql.TxOptions{ Isolation: 0, ReadOnly: true, }, func(tx *sql.Tx) error { row := database. Select(ctx, &sub). From(`gql_user_wh_sub`). Where(sq.And{sq.Expr(`id = ?`, id), filter}). RunWith(tx). QueryRowContext(ctx) if err := row.Scan(database.Scan(ctx, &sub)...); err != nil { return err } return nil }); err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("no user webhook by ID %d found for this user", id) } return nil, err } return &sub, nil } // Webhook is the resolver for the webhook field. func (r *queryResolver) Webhook(ctx context.Context) (model.WebhookPayload, error) { raw, err := corewebhooks.Payload(ctx) if err != nil { return nil, err } payload, ok := raw.(model.WebhookPayload) if !ok { panic("Invalid webhook payload context") } return payload, nil } // ApprovedRev is the resolver for the approvedRev field. func (r *spaceResolver) ApprovedRev(ctx context.Context, obj *model.Space) (string, error) { // A field resolver rather than a struct field: ListSpaces deliberately does // not open repositories, so resolving the approved head of every space in a // listing would make the cheapest view in the service the most expensive // one. Here it is paid for only by a query that asks for it. sp, err := r.reader.OpenSpace(ctx, core.SpaceRef{Owner: obj.Owner, Name: obj.Name}) if err != nil { return "", err } return r.reader.ResolveRev(ctx, sp, service.ApprovedRev) } // Deliveries is the resolver for the deliveries field. func (r *userWebhookSubscriptionResolver) Deliveries(ctx context.Context, obj *model.UserWebhookSubscription, cursor *model1.Cursor) (*model.WebhookDeliveryCursor, error) { if cursor == nil { cursor = model1.NewCursor(nil) } var deliveries []*model.WebhookDelivery if err := database.WithTx(ctx, &sql.TxOptions{ Isolation: 0, ReadOnly: true, }, func(tx *sql.Tx) error { d := (&model.WebhookDelivery{}). WithName(`user`). As(`delivery`) query := database. Select(ctx, d). From(`gql_user_wh_delivery delivery`). Where(`delivery.subscription_id = ?`, obj.ID) deliveries, cursor = d.QueryWithCursor(ctx, tx, query, cursor) return nil }); err != nil { return nil, err } return &model.WebhookDeliveryCursor{Results: deliveries, Cursor: cursor}, nil } // Sample is the resolver for the sample field. func (r *userWebhookSubscriptionResolver) Sample(ctx context.Context, obj *model.UserWebhookSubscription, event model.WebhookEvent) (string, error) { payloadUUID := uuid.New() webhook := corewebhooks.WebhookContext{ User: auth.ForContext(ctx), PayloadUUID: payloadUUID, Name: "user", Event: event.String(), Subscription: &corewebhooks.WebhookSubscription{ ID: obj.ID, URL: obj.URL, Query: obj.Query, AuthMethod: obj.AuthMethod, TokenHash: obj.TokenHash, Grants: obj.Grants, ClientID: obj.ClientID, Expires: obj.Expires, NodeID: obj.NodeID, }, } switch event { case model.WebhookEventProposalOpened, model.WebhookEventProposalMerged, model.WebhookEventProposalRejected: // A synthetic proposal so the sample renders without a DB round-trip. The // payload carries a fully-populated *model.Proposal, so ProposalEvent's // generated field resolvers read it straight off the struct. now := time.Now().UTC() webhook.Payload = &model.ProposalEvent{ UUID: payloadUUID.String(), Event: event, Date: now, Proposal: &model.Proposal{ ID: -1, Space: "~owner/example", Title: "Example proposal", Rationale: "A sample proposal for webhook testing.", BaseRev: "0000000000000000000000000000000000000000", Branch: "proposals/0", State: model.ProposalStateOpen, Agent: "example-agent/sample", AgentSession: "00000000-0000-0000-0000-000000000000", Created: now, }, } default: return "", fmt.Errorf("unsupported event %s", event.String()) } subctx := corewebhooks.Context(ctx, webhook.Payload) bytes, err := webhook.Exec(subctx, server.ForContext(ctx).Schema) if err != nil { return "", err } return string(bytes), nil } // Subscription is the resolver for the subscription field. func (r *webhookDeliveryResolver) Subscription(ctx context.Context, obj *model.WebhookDelivery) (model.WebhookSubscription, error) { if obj.Name == "" { panic("WebhookDelivery without name") } // XXX: This could use a loader but it's unlikely to be a bottleneck var sub model.WebhookSubscription if err := database.WithTx(ctx, &sql.TxOptions{ Isolation: 0, ReadOnly: true, }, func(tx *sql.Tx) error { // XXX: This needs some work to generalize to other kinds of webhooks var subscription interface { model.WebhookSubscription database.Model } = nil switch obj.Name { case "user": subscription = (&model.UserWebhookSubscription{}).As(`sub`) default: panic(fmt.Errorf("unknown webhook name %q", obj.Name)) } // Note: No filter needed because, if we have access to the delivery, // we also have access to the subscription. row := database. Select(ctx, subscription). From(`gql_`+obj.Name+`_wh_sub sub`). Where(`sub.id = ?`, obj.SubscriptionID). RunWith(tx). QueryRowContext(ctx) if err := row.Scan(database.Scan(ctx, subscription)...); err != nil { return err } sub = subscription return nil }); err != nil { return nil, err } return sub, nil } // Mutation returns api.MutationResolver implementation. func (r *Resolver) Mutation() api.MutationResolver { return &mutationResolver{r} } // Project returns api.ProjectResolver implementation. func (r *Resolver) Project() api.ProjectResolver { return &projectResolver{r} } // Query returns api.QueryResolver implementation. func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} } // Space returns api.SpaceResolver implementation. func (r *Resolver) Space() api.SpaceResolver { return &spaceResolver{r} } // UserWebhookSubscription returns api.UserWebhookSubscriptionResolver implementation. func (r *Resolver) UserWebhookSubscription() api.UserWebhookSubscriptionResolver { return &userWebhookSubscriptionResolver{r} } // WebhookDelivery returns api.WebhookDeliveryResolver implementation. func (r *Resolver) WebhookDelivery() api.WebhookDeliveryResolver { return &webhookDeliveryResolver{r} } type mutationResolver struct{ *Resolver } type projectResolver struct{ *Resolver } type queryResolver struct{ *Resolver } type spaceResolver struct{ *Resolver } type userWebhookSubscriptionResolver struct{ *Resolver } type webhookDeliveryResolver struct{ *Resolver } // !!! WARNING !!! // The code below was going to be deleted when updating resolvers. It has been copied here so you have // one last chance to move it out of harms way if you want. There are two reasons this happens: // - When renaming or deleting a resolver the old code will be put in here. You can safely delete // it when you're done. // - You have helper methods in this file. Move them out to keep these resolver files clean. func (r *queryResolver) archive(ctx context.Context, space string, rev *string) (*doc.Archive, map[string][]byte, string, bool, error) { ref, err := core.ParseSpaceRef(strings.TrimSpace(space)) if err != nil { return nil, nil, "", false, fmt.Errorf("space: %w", err) } want := deref(rev) if err := service.ValidateReadRev(want); err != nil { return nil, nil, "", false, fmt.Errorf("rev: %w", err) } sp, err := r.reader.OpenSpace(ctx, ref) if err != nil { return nil, nil, "", false, err } arc, bodies, err := r.reader.Archive(ctx, sp, want) if err != nil { return nil, nil, "", false, err } return arc, bodies, arc.Rev, want != service.ApprovedRev, nil } func address(arc *doc.Archive, id, path string) (*doc.Page, bool, error) { id = strings.TrimSpace(id) path = strings.TrimSpace(path) switch { case id == "" && path == "": return nil, false, errors.New("give either id or path: a document has to be addressed") case id != "" && path != "": return nil, false, fmt.Errorf("give either id or path, not both: %q and %q cannot both name the document wanted", id, path) } if path != "" { if err := core.ValidateDocPath(path); err != nil { return nil, false, fmt.Errorf("path: %w", err) } page, ok := arc.ByPath(path) return page, ok, nil } if page, ok := arc.Page(id); ok { return page, true, nil } // Nothing is addressed by that id. Saying only "no such document" would // hide the one case a caller cannot otherwise diagnose: an id two documents // claim resolves to neither of them, and the collision is invisible from // outside the space. var claimed []string for _, p := range arc.All() { if p.DocID == id { claimed = append(claimed, p.Path) } } if len(claimed) > 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", id, len(claimed), strings.Join(claimed, ", ")) } return nil, false, nil } func spaceModel(sp *service.Space) *model.Space { return &model.Space{ Owner: sp.Ref.Owner, Name: sp.Ref.Name, Ref: sp.Ref.String(), Created: sp.Created, } } func spaceModels(spaces []*service.Space) []*model.Space { out := make([]*model.Space, 0, len(spaces)) for _, sp := range spaces { out = append(out, spaceModel(sp)) } return out } func projectModel(ref core.ProjectRef) *model.Project { return &model.Project{ Owner: ref.Owner, Name: ref.Name, Ref: ref.String(), Meta: ref.IsMeta(), } } func documentModel(space core.SpaceRef, rev string, pinned bool, page *doc.Page, body []byte) *model.Document { front, _ := doc.ParseFront(body) return &model.Document{ Space: space.String(), ID: page.ID, DocID: nilIfEmpty(page.DocID), Path: page.Path, Rev: rev, Blob: page.Blob, Pinned: pinned, Title: page.Title, Status: string(page.Status), Section: page.Section, Summary: page.Summary, Type: front.Type, Supersedes: front.Supersedes, Tags: nonNil(page.Tags), Owners: nonNil(front.Owners), Markdown: string(body), } } func proposalModel(p Proposal) (*model.Proposal, error) { state, err := proposalState(p.State) if err != nil { return nil, err } m := &model.Proposal{ ID: p.ID, Space: p.Space.String(), Title: p.Title, Rationale: p.Rationale, BaseRev: p.BaseRev, Branch: p.Branch, State: state, MergedRev: nilIfEmpty(p.MergedRev), Agent: p.Agent, AgentSession: p.AgentSession, Created: p.Created, Resolved: p.Resolved, } // Approval is empty until a proposal merges, and the difference between // human and policy approval is one a reader must be able to see: reporting // either value for an unmerged proposal would launder unreviewed agent // output as blessed, or claim a review that did not happen. if p.Approval != "" { approval, err := proposalApproval(p.Approval) if err != nil { return nil, err } m.Approval = &approval } return m, nil } func proposalState(s core.ProposalState) (model.ProposalState, error) { out := model.ProposalState(strings.ToUpper(string(s))) if !out.IsValid() { return "", fmt.Errorf("proposal state %q is not one of %v", s, model.AllProposalState) } return out, nil } func proposalApproval(a core.Approval) (model.Approval, error) { out := model.Approval(strings.ToUpper(string(a))) if !out.IsValid() { return "", fmt.Errorf("approval %q is not one of %v", a, model.AllApproval) } return out, nil } func spaceRef(owner, name string) (core.SpaceRef, error) { ref := core.SpaceRef{Owner: strings.TrimSpace(owner), Name: strings.TrimSpace(name)} if err := core.ValidateOwner(ref.Owner); err != nil { return core.SpaceRef{}, fmt.Errorf("owner: %w", err) } if err := core.ValidateSpaceName(ref.Name); err != nil { return core.SpaceRef{}, fmt.Errorf("name: %w", err) } return ref, nil } func projectRef(owner, name string) (core.ProjectRef, error) { ref := core.ProjectRef{Owner: strings.TrimSpace(owner), Name: strings.TrimSpace(name)} if err := core.ValidateOwner(ref.Owner); err != nil { return core.ProjectRef{}, fmt.Errorf("owner: %w", err) } if ref.IsMeta() { return ref, nil } if err := core.ValidateProjectName(ref.Name); err != nil { return core.ProjectRef{}, fmt.Errorf("name: %w", err) } return ref, nil } func orNull(err error) error { if errors.Is(err, service.ErrNotFound) { return nil } return err } func deref(s *string) string { if s == nil { return "" } return *s } func nilIfEmpty(s string) *string { if s == "" { return nil } return &s } func nonNil(s []string) []string { if s == nil { return []string{} } return s }