paste.sr.ht/api/graph/schema.resolvers.go

728 lines
20 KiB
Go

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.
import (
"context"
"crypto/sha1"
"database/sql"
"encoding/hex"
"fmt"
"io"
"mime"
"net/url"
"strings"
"time"
"unicode/utf8"
"git.sr.ht/~sircmpwn/core-go/auth"
"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"
"git.sr.ht/~sircmpwn/core-go/valid"
corewebhooks "git.sr.ht/~sircmpwn/core-go/webhooks"
"git.sr.ht/~sircmpwn/paste.sr.ht/api/account"
"git.sr.ht/~sircmpwn/paste.sr.ht/api/graph/api"
"git.sr.ht/~sircmpwn/paste.sr.ht/api/graph/model"
"git.sr.ht/~sircmpwn/paste.sr.ht/api/loaders"
"git.sr.ht/~sircmpwn/paste.sr.ht/api/webhooks"
"github.com/99designs/gqlgen/graphql"
sq "github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/lib/pq"
)
// Hash is the resolver for the hash field.
func (r *fileResolver) Hash(ctx context.Context, obj *model.File) (string, error) {
blob, err := loaders.ForContext(ctx).BlobsByID.Load(obj.BlobID)
if err != nil {
panic(err)
}
return blob.SHA, nil
}
// Contents is the resolver for the contents field.
func (r *fileResolver) Contents(ctx context.Context, obj *model.File) (*model.URL, error) {
blob, err := loaders.ForContext(ctx).BlobsByID.Load(obj.BlobID)
if err != nil {
panic(err)
}
origin := config.GetOrigin(config.ForContext(ctx), "paste.sr.ht", true)
uri := fmt.Sprintf("%s/query/blob/%s", origin, blob.SHA)
url, err := url.Parse(uri)
if err != nil {
panic(err)
}
return &model.URL{url}, nil
}
// Create is the resolver for the create field.
func (r *mutationResolver) Create(ctx context.Context, files []*graphql.Upload, visibility model.Visibility) (*model.Paste, error) {
var paste model.Paste
valid := valid.New(ctx)
nameSet := make(map[string]interface{})
for i, file := range files {
mt, params, err := mime.ParseMediaType(file.ContentType)
if err != nil {
return nil, err
}
valid.Expect(strings.HasPrefix(mt, "text/"),
"File index %d with non-text mimetypes is not acceptable", i)
if enc, ok := params["charset"]; ok {
valid.Expect(strings.ToLower(enc) == "utf-8",
"File index %d with non-UTF-8 encoding is not acceptable", i)
}
if _, ok := nameSet[file.Filename]; ok {
valid.Error("File name '%s' cannot be specified more than once",
file.Filename)
}
nameSet[file.Filename] = nil
}
if !valid.Ok() {
return nil, nil
}
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
pasteHash := sha1.New()
row := tx.QueryRowContext(ctx, `
INSERT INTO paste (
created, updated, user_id, visibility
) VALUES (
NOW() at time zone 'utc',
NOW() at time zone 'utc',
$1, $2
) RETURNING id;`,
auth.ForContext(ctx).UserID,
strings.ToLower(visibility.String()))
var pasteId int
if err := row.Scan(&pasteId); err != nil {
return err
}
for i, file := range files {
fileHash := sha1.New()
reader := NewContextReader(ctx, io.TeeReader(file.File, fileHash))
bytes, err := io.ReadAll(reader)
if err != nil {
return err
}
valid.Expect(utf8.Valid(bytes),
"File index %d has unacceptable non-UTF-8 data", i)
if !valid.Ok() {
// valid.Expect handles errors for us but we need to return a
// non-nil error to WithTx so that the transaction is aborted.
return fmt.Errorf("Invalid file input")
}
content := string(bytes)
hash := fileHash.Sum(nil)
checksum := hex.EncodeToString(hash[:])
row := tx.QueryRowContext(ctx, `
INSERT INTO blob (
created, updated, sha, contents
) VALUES (
NOW() at time zone 'utc',
NOW() at time zone 'utc',
$1, $2
)
ON CONFLICT ON CONSTRAINT sha_unique
DO UPDATE SET updated = NOW() at time zone 'utc'
RETURNING id;
`, checksum, content)
var blobId int
if err := row.Scan(&blobId); err != nil {
return err
}
var filename *string
if file.Filename == "" {
pasteHash.Write([]byte{0})
filename = nil
} else {
fmt.Fprintf(pasteHash, "%s", file.Filename)
filename = &file.Filename
}
fmt.Fprintf(pasteHash, "%s", checksum)
_, err = tx.ExecContext(ctx, `
INSERT INTO paste_file (
created, updated, filename, blob_id, paste_id
) VALUES (
NOW() at time zone 'utc',
NOW() at time zone 'utc',
$1, $2, $3
);`,
filename, blobId, pasteId)
if err != nil {
return err
}
}
fmt.Fprintf(pasteHash, "%d", auth.ForContext(ctx).UserID)
fmt.Fprintf(pasteHash, "%d", pasteId)
hash := pasteHash.Sum(nil)
checksum := hex.EncodeToString(hash[:])
_, err := tx.ExecContext(ctx,
`UPDATE paste SET sha = $1 WHERE id = $2`,
checksum, pasteId)
paste.ID = checksum
paste.PKID = pasteId
paste.Created = time.Now().UTC()
paste.UserID = auth.ForContext(ctx).UserID
paste.RawVisibility = strings.ToLower(visibility.String())
return err
}); err != nil {
return nil, err
}
if !valid.Ok() {
return nil, nil
}
webhooks.DeliverUserPasteEvent(ctx, model.WebhookEventPasteCreated, &paste)
return &paste, nil
}
// Update is the resolver for the update field.
func (r *mutationResolver) Update(ctx context.Context, id string, visibility model.Visibility) (*model.Paste, error) {
var paste model.Paste
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `
UPDATE paste
SET visibility = $1
WHERE sha = $2 AND user_id = $3
RETURNING
id, sha, created, user_id, visibility;`,
strings.ToLower(visibility.String()),
id, auth.ForContext(ctx).UserID)
return row.Scan(&paste.PKID, &paste.ID,
&paste.Created, &paste.UserID, &paste.RawVisibility)
}); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
webhooks.DeliverUserPasteEvent(ctx, model.WebhookEventPasteUpdated, &paste)
return &paste, nil
}
// Delete is the resolver for the delete field.
func (r *mutationResolver) Delete(ctx context.Context, id string) (*model.Paste, error) {
var paste model.Paste
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
// TODO: This can leak blobs
row := tx.QueryRowContext(ctx, `
DELETE FROM paste
WHERE sha = $1 AND user_id = $2
RETURNING
id, sha, created, user_id, visibility;`,
id, auth.ForContext(ctx).UserID)
err := row.Scan(&paste.PKID, &paste.ID,
&paste.Created, &paste.UserID, &paste.RawVisibility)
if err != nil {
return err
}
webhooks.DeliverUserPasteEvent(ctx, model.WebhookEventPasteDeleted, &paste)
return nil
}); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &paste, nil
}
// CreateUserWebhook is the resolver for the createUserWebhook field.
func (r *mutationResolver) CreateUserWebhook(ctx context.Context, config model.UserWebhookInput) (model.WebhookSubscription, error) {
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.WebhookEventPasteCreated, model.WebhookEventPasteUpdated,
model.WebhookEventPasteDeleted:
access = "PASTES"
default:
return nil, fmt.Errorf("Unsupported event %s", ev.String())
}
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
}
// DeleteUserWebhook is the resolver for the deleteUserWebhook field.
func (r *mutationResolver) DeleteUserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
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, fmt.Errorf("No user webhook by ID %d found for this user", id)
}
return nil, err
}
return &sub, nil
}
// DeleteUser is the resolver for the deleteUser field.
func (r *mutationResolver) DeleteUser(ctx context.Context) (int, error) {
user := auth.ForContext(ctx)
account.Delete(ctx, user.UserID, user.Username)
return user.UserID, nil
}
// Files is the resolver for the files field.
func (r *pasteResolver) Files(ctx context.Context, obj *model.Paste) ([]*model.File, error) {
var files []*model.File
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
file := (&model.File{}).As(`file`)
query := database.
Select(ctx, file).
From(`paste_file file`).
Where(`file.paste_id = ?`, obj.PKID)
rows, err := query.RunWith(tx).QueryContext(ctx)
if err != nil {
return nil
}
defer rows.Close()
for rows.Next() {
var file model.File
if err := rows.Scan(database.Scan(ctx, &file)...); err != nil {
return err
}
files = append(files, &file)
}
return nil
}); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return files, nil
}
// User is the resolver for the user field.
func (r *pasteResolver) User(ctx context.Context, obj *model.Paste) (model.Entity, error) {
return loaders.ForContext(ctx).UsersByID.Load(obj.UserID)
}
// Version is the resolver for the version field.
func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
return &model.Version{
Major: 0,
Minor: 0,
Patch: 0,
DeprecationDate: nil,
}, nil
}
// Me is the resolver for the me field.
func (r *queryResolver) Me(ctx context.Context) (*model.User, error) {
user := auth.ForContext(ctx)
return &model.User{
ID: user.UserID,
Created: user.Created,
Username: user.Username,
}, nil
}
// User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context, username string) (*model.User, error) {
return loaders.ForContext(ctx).UsersByName.Load(username)
}
// Pastes is the resolver for the pastes field.
func (r *queryResolver) Pastes(ctx context.Context, cursor *coremodel.Cursor) (*model.PasteCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(nil)
}
var pastes []*model.Paste
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
paste := (&model.Paste{}).As(`paste`)
query := database.
Select(ctx, paste).
From(`paste`).
Where(sq.And{
// TODO: Remove this and set the column to non-nullable after
// refactoring the frontend to create new pastes via GQL
sq.Expr(`paste.sha IS NOT NULL`),
sq.Expr(`paste.user_id = ?`, auth.ForContext(ctx).UserID),
})
pastes, cursor = paste.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.PasteCursor{pastes, cursor}, nil
}
// Paste is the resolver for the paste field.
func (r *queryResolver) Paste(ctx context.Context, id string) (*model.Paste, error) {
return loaders.ForContext(ctx).PastesBySHA.Load(id)
}
// UserWebhooks is the resolver for the userWebhooks field.
func (r *queryResolver) UserWebhooks(ctx context.Context, cursor *coremodel.Cursor) (*model.WebhookSubscriptionCursor, error) {
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
}
// UserWebhook is the resolver for the userWebhook field.
func (r *queryResolver) UserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
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, 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
}
// Pastes is the resolver for the pastes field.
func (r *userResolver) Pastes(ctx context.Context, obj *model.User, cursor *coremodel.Cursor) (*model.PasteCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(nil)
}
var pastes []*model.Paste
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
paste := (&model.Paste{}).As(`paste`)
query := database.
Select(ctx, paste).
From(`paste`).
Where(sq.And{
sq.Expr(`paste.sha IS NOT NULL`),
sq.Expr(`paste.user_id = ?`, obj.ID),
sq.Or{
sq.Expr(`paste.user_id = ?`, auth.ForContext(ctx).UserID),
sq.Expr(`paste.visibility = 'public'`),
},
})
pastes, cursor = paste.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.PasteCursor{pastes, cursor}, nil
}
// Client is the resolver for the client field.
func (r *userWebhookSubscriptionResolver) Client(ctx context.Context, obj *model.UserWebhookSubscription) (*model.OAuthClient, error) {
if obj.ClientID == nil {
return nil, nil
}
return &model.OAuthClient{
UUID: *obj.ClientID,
}, nil
}
// Deliveries is the resolver for the deliveries field.
func (r *userWebhookSubscriptionResolver) Deliveries(ctx context.Context, obj *model.UserWebhookSubscription, cursor *coremodel.Cursor) (*model.WebhookDeliveryCursor, error) {
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(`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{deliveries, 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,
},
}
auth := auth.ForContext(ctx)
switch event {
case model.WebhookEventPasteCreated, model.WebhookEventPasteUpdated,
model.WebhookEventPasteDeleted:
webhook.Payload = &model.PasteEvent{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
Paste: &model.Paste{
ID: "943a702d06f34599aee1f8da8ef9f7296031d699",
Created: time.Now().UTC(),
PKID: -1,
UserID: auth.UserID,
RawVisibility: "public",
},
}
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
}
// File returns api.FileResolver implementation.
func (r *Resolver) File() api.FileResolver { return &fileResolver{r} }
// Mutation returns api.MutationResolver implementation.
func (r *Resolver) Mutation() api.MutationResolver { return &mutationResolver{r} }
// Paste returns api.PasteResolver implementation.
func (r *Resolver) Paste() api.PasteResolver { return &pasteResolver{r} }
// Query returns api.QueryResolver implementation.
func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} }
// User returns api.UserResolver implementation.
func (r *Resolver) User() api.UserResolver { return &userResolver{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 fileResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type pasteResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
type userWebhookSubscriptionResolver struct{ *Resolver }
type webhookDeliveryResolver struct{ *Resolver }