package webhooks
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/99designs/gqlgen/complexity"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/executor"
"github.com/google/uuid"
"git.sr.ht/~sircmpwn/core-go/auth"
"git.sr.ht/~sircmpwn/core-go/server"
)
type contextKey struct {
name string
}
var payloadContextKey = &contextKey{"webhookPayloadContext"}
type WebhookContext struct {
Name string
Event string
User *auth.AuthContext
Payload any
PayloadUUID uuid.UUID
Subscription *WebhookSubscription
}
// Prepares an context for a specific webhook delivery.
func Context(ctx context.Context, payload any) context.Context {
return context.WithValue(ctx, payloadContextKey, payload)
}
// Returns the active payload for a webhook context.
func Payload(ctx context.Context) (any, error) {
payload := ctx.Value(payloadContextKey)
if payload == nil {
return nil, errors.New("cannot use this resolver without an active webhook context")
}
return payload, nil
}
// Executes the GraphQL query prepared stored in the WebhookContext. Handles
// the configuration of a secondary authentication and GraphQL context.
func (webhook *WebhookContext) Exec(ctx context.Context,
schema graphql.ExecutableSchema) ([]byte, error) {
var (
err error
tokenHash [64]byte
)
sub := webhook.Subscription
switch sub.AuthMethod {
case auth.AUTH_OAUTH2:
tslice, err := hex.DecodeString(*sub.TokenHash)
if err != nil {
panic(err)
}
if sub.Expires == nil {
panic(fmt.Errorf("OAuth 2 token has no expiry?"))
}
copy(tokenHash[:], tslice)
ctx, err = auth.WebhookAuth(ctx, webhook.User,
tokenHash, *sub.Grants, sub.ClientID, sub.Expires)
if err != nil {
// TODO: This codepath can occur when the token has
// expired, and we may want to communicate this to the
// user.
return nil, err
}
case auth.AUTH_INTERNAL:
ctx, err = auth.WebhookAuth(ctx, webhook.User,
tokenHash, "", nil, nil)
if err != nil {
panic(err)
}
default:
panic(fmt.Errorf("unsupported authentication context for webhook"))
}
exec := executor.New(schema)
params := graphql.RawParams{
Query: sub.Query,
ReadTime: graphql.TraceTiming{
Start: graphql.Now(),
End: graphql.Now(),
},
}
ctx = graphql.StartOperationTrace(ctx)
rc, errors := exec.CreateOperationContext(ctx, ¶ms)
if errors != nil {
panic(errors)
}
rc.RecoverFunc = server.EmailRecover
op := rc.Doc.Operations.ForName(rc.OperationName)
complexity := complexity.Calculate(schema, op, rc.Variables)
srv := server.ForContext(ctx)
if complexity > srv.MaxComplexity {
// TODO: This doesn't bubble up to the user well
return nil, fmt.Errorf("operation has complexity %d, which exceeds the maximum of %d",
complexity, srv.MaxComplexity)
}
var resp graphql.ResponseHandler
ctx = graphql.WithOperationContext(ctx, rc)
resp, ctx = exec.DispatchOperation(ctx, rc)
payload, err := json.Marshal(resp(ctx))
if err != nil {
panic(err)
}
return payload, nil
}
// Validates the given query against the provided schema and returns any errors
// should they be found, or nil if the query passes validation.
func Validate(schema graphql.ExecutableSchema, query string) error {
// XXX: We would create less garbage if we ran the validator ourselves
// instead of letting gqlgen do it for us via CreateOperationContext
exec := executor.New(schema)
params := graphql.RawParams{
Query: query,
ReadTime: graphql.TraceTiming{
Start: graphql.Now(),
End: graphql.Now(),
},
}
ctx := graphql.StartOperationTrace(context.TODO())
_, errors := exec.CreateOperationContext(ctx, ¶ms)
if errors != nil {
return fmt.Errorf("error validating webhook query: %s", errors.Error())
}
return nil
}