From e7c61e7404881833c22b61d7ecd9755dba2220dd Mon Sep 17 00:00:00 2001 From: Adnan Maolood Date: Wed, 12 Jan 2022 09:13:24 -0500 Subject: [PATCH] api/graph: Rig up user webhook queries and mutations --- api/go.mod | 2 +- api/go.sum | 4 +- api/graph/schema.resolvers.go | 231 ++++++++++++++++++++++++++++++++-- 3 files changed, 226 insertions(+), 11 deletions(-) diff --git a/api/go.mod b/api/go.mod index e1010c5..23d38a2 100644 --- a/api/go.mod +++ b/api/go.mod @@ -3,7 +3,7 @@ module git.sr.ht/~sircmpwn/git.sr.ht/api go 1.14 require ( - git.sr.ht/~sircmpwn/core-go v0.0.0-20211218082756-f762ad220360 + git.sr.ht/~sircmpwn/core-go v0.0.0-20220112154231-e28d47cf5957 github.com/99designs/gqlgen v0.14.0 github.com/Masterminds/squirrel v1.4.0 github.com/go-git/go-git/v5 v5.0.0 diff --git a/api/go.sum b/api/go.sum index 221dc21..cf722cc 100644 --- a/api/go.sum +++ b/api/go.sum @@ -31,8 +31,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -git.sr.ht/~sircmpwn/core-go v0.0.0-20211218082756-f762ad220360 h1:KZwWE8xwafnRCldGTHeeuYH3QKhlrgsCqukIgXHzUgs= -git.sr.ht/~sircmpwn/core-go v0.0.0-20211218082756-f762ad220360/go.mod h1:uUqzeO5OLl/nRZfPk0igIAweRZiVwUmu/OGYfjS9fWc= +git.sr.ht/~sircmpwn/core-go v0.0.0-20220112154231-e28d47cf5957 h1:TjvgAEU7+tsevGSTJU1DMhV7Ue6GnvMWKVIRxLfPcu0= +git.sr.ht/~sircmpwn/core-go v0.0.0-20220112154231-e28d47cf5957/go.mod h1:uUqzeO5OLl/nRZfPk0igIAweRZiVwUmu/OGYfjS9fWc= git.sr.ht/~sircmpwn/dowork v0.0.0-20210820133136-d3970e97def3 h1:9WCv5cK67s2SiY/R4DWT/OchEsFnfYDz3lbevKxZ4QI= git.sr.ht/~sircmpwn/dowork v0.0.0-20210820133136-d3970e97def3/go.mod h1:8neHEO3503w/rNtttnR0JFpQgM/GFhaafVwvkPsFIDw= git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3 h1:4wDp4BKF7NQqoh73VXpZsB/t1OEhDpz/zEpmdQfbjDk= diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index a1e93c1..fc59e5c 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -14,6 +14,7 @@ import ( "errors" "fmt" "io" + "net/url" "os" "path" "sort" @@ -24,6 +25,8 @@ import ( "git.sr.ht/~sircmpwn/core-go/config" "git.sr.ht/~sircmpwn/core-go/database" coremodel "git.sr.ht/~sircmpwn/core-go/model" + "git.sr.ht/~sircmpwn/core-go/server" + corewebhooks "git.sr.ht/~sircmpwn/core-go/webhooks" "git.sr.ht/~sircmpwn/git.sr.ht/api/graph/api" "git.sr.ht/~sircmpwn/git.sr.ht/api/graph/model" "git.sr.ht/~sircmpwn/git.sr.ht/api/loaders" @@ -34,6 +37,7 @@ import ( "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/storer" + "github.com/lib/pq" minio "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) @@ -628,11 +632,105 @@ func (r *mutationResolver) DeleteArtifact(ctx context.Context, id int) (*model.A } func (r *mutationResolver) CreateWebhook(ctx context.Context, config model.UserWebhookInput) (model.WebhookSubscription, error) { - panic(fmt.Errorf("not implemented")) + schema := server.ForContext(ctx).Schema + if err := corewebhooks.Validate(schema, config.Query); err != nil { + return nil, err + } + + user := auth.ForContext(ctx) + ac, err := corewebhooks.NewAuthConfig(ctx) + if err != nil { + return nil, err + } + + var sub model.UserWebhookSubscription + 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() + // TODO: gqlgen does not support doing anything useful with directives + // on enums at the time of writing, so we have to do a little bit of + // manual fuckery + var access string + switch ev { + case model.WebhookEventRepoCreated, model.WebhookEventRepoUpdate, + model.WebhookEventRepoDeleted: + access = "REPOSITORIES" + } + if !user.Grants.Has(access, auth.RO) { + return nil, fmt.Errorf("Insufficient access granted for webhook event %s", 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") + } + + 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 + user.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 } func (r *mutationResolver) DeleteWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) { - panic(fmt.Errorf("not implemented")) + var sub model.UserWebhookSubscription + + filter, err := corewebhooks.FilterWebhooks(ctx) + if err != nil { + return nil, err + } + + 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, nil + } + return nil, err + } + + return &sub, nil } func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) { @@ -723,15 +821,76 @@ func (r *queryResolver) RepositoryByOwner(ctx context.Context, owner string, rep } func (r *queryResolver) UserWebhooks(ctx context.Context, cursor *coremodel.Cursor) (*model.WebhookSubscriptionCursor, error) { - panic(fmt.Errorf("not implemented")) + if cursor == nil { + cursor = coremodel.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{subs, cursor}, nil } func (r *queryResolver) UserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) { - panic(fmt.Errorf("not implemented")) + var sub model.UserWebhookSubscription + + filter, err := corewebhooks.FilterWebhooks(ctx) + if err != nil { + return nil, err + } + + 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, nil + } + return nil, err + } + + return &sub, nil } func (r *queryResolver) Webhook(ctx context.Context) (model.WebhookPayload, error) { - panic(fmt.Errorf("not implemented")) + 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 } func (r *referenceResolver) Artifacts(ctx context.Context, obj *model.Reference, cursor *coremodel.Cursor) (*model.ArtifactCursor, error) { @@ -1044,19 +1203,75 @@ func (r *userResolver) Repositories(ctx context.Context, obj *model.User, cursor } func (r *userWebhookSubscriptionResolver) Client(ctx context.Context, obj *model.UserWebhookSubscription) (*model.OAuthClient, error) { - panic(fmt.Errorf("not implemented")) + if obj.ClientID == nil { + return nil, nil + } + return &model.OAuthClient{ + UUID: *obj.ClientID, + }, nil } func (r *userWebhookSubscriptionResolver) Deliveries(ctx context.Context, obj *model.UserWebhookSubscription, cursor *coremodel.Cursor) (*model.WebhookDeliveryCursor, error) { - panic(fmt.Errorf("not implemented")) + if cursor == nil { + cursor = coremodel.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(`profile`). + 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{deliveries, cursor}, nil } func (r *userWebhookSubscriptionResolver) Sample(ctx context.Context, obj *model.UserWebhookSubscription, event *model.WebhookEvent) (string, error) { + // TODO panic(fmt.Errorf("not implemented")) } func (r *webhookDeliveryResolver) Subscription(ctx context.Context, obj *model.WebhookDelivery) (model.WebhookSubscription, error) { - panic(fmt.Errorf("not implemented")) + 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 + subscription := (&model.UserWebhookSubscription{}).As(`sub`) + // 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_user_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 } // ACL returns api.ACLResolver implementation. -- 2.38.4