2189 lines
64 KiB
Go
2189 lines
64 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 (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.sr.ht/~emersion/go-emailthreads"
|
|
"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/lists.sr.ht/api/graph/api"
|
|
"git.sr.ht/~sircmpwn/lists.sr.ht/api/graph/model"
|
|
"git.sr.ht/~sircmpwn/lists.sr.ht/api/loaders"
|
|
"git.sr.ht/~sircmpwn/lists.sr.ht/api/webhooks"
|
|
sq "github.com/Masterminds/squirrel"
|
|
_ "github.com/emersion/go-message/charset"
|
|
"github.com/emersion/go-message/mail"
|
|
"github.com/google/uuid"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
func (r *emailResolver) Sender(ctx context.Context, obj *model.Email) (model.Entity, error) {
|
|
if obj.SenderID != nil {
|
|
return loaders.ForContext(ctx).UsersByID.Load(*obj.SenderID)
|
|
}
|
|
list, err := obj.RawHeader.AddressList("From")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(list) != 1 {
|
|
panic(fmt.Errorf("Malformed email %d, multiple senders", obj.ID))
|
|
}
|
|
return &model.Mailbox{
|
|
Name: list[0].Name,
|
|
Address: list[0].Address,
|
|
}, nil
|
|
}
|
|
|
|
func (r *emailResolver) Date(ctx context.Context, obj *model.Email) (*time.Time, error) {
|
|
date, err := obj.RawHeader.Date()
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
return &date, nil
|
|
}
|
|
|
|
func (r *emailResolver) Header(ctx context.Context, obj *model.Email, want string) ([]string, error) {
|
|
var values []string
|
|
iter := obj.RawHeader.FieldsByKey(want)
|
|
for iter.Next() {
|
|
text, err := iter.Text()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values = append(values, text)
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func (r *emailResolver) AddressList(ctx context.Context, obj *model.Email, want string) ([]*model.Mailbox, error) {
|
|
list, err := obj.RawHeader.AddressList(want)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var addrs []*model.Mailbox
|
|
for _, item := range list {
|
|
addrs = append(addrs, &model.Mailbox{
|
|
Name: item.Name,
|
|
Address: item.Address,
|
|
})
|
|
}
|
|
return addrs, nil
|
|
}
|
|
|
|
func (r *emailResolver) Envelope(ctx context.Context, obj *model.Email) (*model.URL, error) {
|
|
origin := config.GetOrigin(config.ForContext(ctx), "lists.sr.ht", true)
|
|
uri := fmt.Sprintf("%s/query/email/%d", origin, obj.ID)
|
|
url, err := url.Parse(uri)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &model.URL{url}, nil
|
|
}
|
|
|
|
func (r *emailResolver) Thread(ctx context.Context, obj *model.Email) (*model.Thread, error) {
|
|
// Regarding the use of an unsafe loader: if you have access to the email
|
|
// object, you have access to the thread also.
|
|
if obj.ThreadID == nil {
|
|
return loaders.ForContext(ctx).ThreadsByIDUnsafe.Load(obj.ID)
|
|
}
|
|
return loaders.ForContext(ctx).ThreadsByIDUnsafe.Load(*obj.ThreadID)
|
|
}
|
|
|
|
func (r *emailResolver) Parent(ctx context.Context, obj *model.Email) (*model.Email, error) {
|
|
if obj.ParentID == nil {
|
|
return nil, nil
|
|
}
|
|
// Regarding the use of an unsafe loader: if you have access to the email
|
|
// object, you have access to its parent also.
|
|
return loaders.ForContext(ctx).EmailsByIDUnsafe.Load(*obj.ParentID)
|
|
}
|
|
|
|
func (r *emailResolver) Patchset(ctx context.Context, obj *model.Email) (*model.Patchset, error) {
|
|
if obj.PatchsetID == nil {
|
|
return nil, nil
|
|
}
|
|
// Regarding the use of an unsafe loader: if you have access to the email
|
|
// object, you have access to the patchset also.
|
|
return loaders.ForContext(ctx).PatchsetsByIDUnsafe.Load(*obj.PatchsetID)
|
|
}
|
|
|
|
func (r *emailResolver) List(ctx context.Context, obj *model.Email) (*model.MailingList, error) {
|
|
return loaders.ForContext(ctx).MailingListsByID.Load(obj.MailingListID)
|
|
}
|
|
|
|
func (r *mailingListResolver) Owner(ctx context.Context, obj *model.MailingList) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).UsersByID.Load(obj.OwnerID)
|
|
}
|
|
|
|
func (r *mailingListResolver) Threads(ctx context.Context, obj *model.MailingList, cursor *coremodel.Cursor) (*model.ThreadCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var threads []*model.Thread
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
thread := (&model.Thread{}).As(`thread`)
|
|
query := database.
|
|
Select(ctx, thread).
|
|
From(`email thread`).
|
|
Where(`thread.list_id = ?`, obj.ID).
|
|
Where(`thread.thread_id IS NULL`)
|
|
threads, cursor = thread.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.ThreadCursor{threads, cursor}, nil
|
|
}
|
|
|
|
func (r *mailingListResolver) Emails(ctx context.Context, obj *model.MailingList, cursor *coremodel.Cursor) (*model.EmailCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var emails []*model.Email
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
email := (&model.Email{}).As(`email`)
|
|
query := database.
|
|
Select(ctx, email).
|
|
From(`email`).
|
|
Where(`email.list_id = ?`, obj.ID).
|
|
OrderBy("email.created DESC")
|
|
emails, cursor = email.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.EmailCursor{emails, cursor}, nil
|
|
}
|
|
|
|
func (r *mailingListResolver) Patches(ctx context.Context, obj *model.MailingList, cursor *coremodel.Cursor) (*model.PatchsetCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var patches []*model.Patchset
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
patch := (&model.Patchset{}).As(`patch`)
|
|
query := database.
|
|
Select(ctx, patch).
|
|
From(`patchset patch`).
|
|
Where(`patch.list_id = ?`, obj.ID)
|
|
patches, cursor = patch.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.PatchsetCursor{patches, cursor}, nil
|
|
}
|
|
|
|
func (r *mailingListResolver) Access(ctx context.Context, obj *model.MailingList) (model.ACL, error) {
|
|
if obj.AccessID != nil {
|
|
return loaders.ForContext(ctx).ACLsByID.Load(*obj.AccessID)
|
|
}
|
|
p := obj.Access
|
|
return &model.GeneralACL{
|
|
Browse: p&model.ACCESS_BROWSE != 0,
|
|
Reply: p&model.ACCESS_REPLY != 0,
|
|
Post: p&model.ACCESS_POST != 0,
|
|
Moderate: p&model.ACCESS_MODERATE != 0,
|
|
}, nil
|
|
}
|
|
|
|
func (r *mailingListResolver) Subscription(ctx context.Context, obj *model.MailingList) (*model.MailingListSubscription, error) {
|
|
if obj.SubscriptionID == nil {
|
|
return nil, nil
|
|
}
|
|
sub, err := loaders.ForContext(ctx).SubscriptionsByIDUnsafe.Load(*obj.SubscriptionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sub.(*model.MailingListSubscription), nil
|
|
}
|
|
|
|
func (r *mailingListResolver) Archive(ctx context.Context, obj *model.MailingList) (*model.URL, error) {
|
|
origin := config.GetOrigin(config.ForContext(ctx), "lists.sr.ht", true)
|
|
uri := fmt.Sprintf("%s/query/list/%d.mbox", origin, obj.ID)
|
|
url, err := url.Parse(uri)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &model.URL{url}, nil
|
|
}
|
|
|
|
func (r *mailingListResolver) Last30days(ctx context.Context, obj *model.MailingList) (*model.URL, error) {
|
|
origin := config.GetOrigin(config.ForContext(ctx), "lists.sr.ht", true)
|
|
uri := fmt.Sprintf("%s/query/list/%d.mbox?since=30", origin, obj.ID)
|
|
url, err := url.Parse(uri)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &model.URL{url}, nil
|
|
}
|
|
|
|
func (r *mailingListResolver) ACL(ctx context.Context, obj *model.MailingList, cursor *coremodel.Cursor) (*model.MailingListACLCursor, error) {
|
|
if obj.OwnerID != auth.ForContext(ctx).UserID {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var acls []*model.MailingListACL
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
acl := (&model.MailingListACL{}).As(`acl`)
|
|
query := database.
|
|
Select(ctx, acl).
|
|
From(`access acl`).
|
|
Where(`acl.list_id = ?`, obj.ID)
|
|
acls, cursor = acl.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.MailingListACLCursor{acls, cursor}, nil
|
|
}
|
|
|
|
func (r *mailingListResolver) Webhooks(ctx context.Context, obj *model.MailingList, 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.MailingListWebhookSubscription{}).As(`sub`)
|
|
query := database.
|
|
Select(ctx, sub).
|
|
From(`gql_list_wh_sub sub`).
|
|
Where(sq.And{sq.Expr(`list_id = ?`, obj.ID), filter})
|
|
subs, cursor = sub.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.WebhookSubscriptionCursor{subs, cursor}, nil
|
|
}
|
|
|
|
func (r *mailingListResolver) Webhook(ctx context.Context, obj *model.MailingList, id int) (model.WebhookSubscription, error) {
|
|
var sub model.MailingListWebhookSubscription
|
|
|
|
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_list_wh_sub`).
|
|
Where(sq.And{
|
|
sq.Expr(`id = ?`, id),
|
|
sq.Expr(`list_id = ?`, obj.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 mailing list webhook by ID %d found for this user", id)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return &sub, nil
|
|
}
|
|
|
|
func (r *mailingListACLResolver) List(ctx context.Context, obj *model.MailingListACL) (*model.MailingList, error) {
|
|
return loaders.ForContext(ctx).MailingListsByID.Load(obj.MailingListID)
|
|
}
|
|
|
|
func (r *mailingListACLResolver) Entity(ctx context.Context, obj *model.MailingListACL) (model.Entity, error) {
|
|
if obj.UserID != nil {
|
|
return loaders.ForContext(ctx).UsersByID.Load(*obj.UserID)
|
|
} else if obj.Email != nil {
|
|
addr, err := mail.ParseAddress(*obj.Email)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &model.Mailbox{
|
|
Name: addr.Name,
|
|
Address: addr.Address,
|
|
}, nil
|
|
} else {
|
|
panic(fmt.Errorf("Invalid ACL record %d", obj.ID))
|
|
}
|
|
}
|
|
|
|
func (r *mailingListSubscriptionResolver) List(ctx context.Context, obj *model.MailingListSubscription) (*model.MailingList, error) {
|
|
// XXX: We could use an unsafe resolver here if we wrote one
|
|
return loaders.ForContext(ctx).MailingListsByID.Load(obj.ListID)
|
|
}
|
|
|
|
func (r *mailingListWebhookSubscriptionResolver) Client(ctx context.Context, obj *model.MailingListWebhookSubscription) (*model.OAuthClient, error) {
|
|
if obj.ClientID == nil {
|
|
return nil, nil
|
|
}
|
|
return &model.OAuthClient{
|
|
UUID: *obj.ClientID,
|
|
}, nil
|
|
}
|
|
|
|
func (r *mailingListWebhookSubscriptionResolver) Deliveries(ctx context.Context, obj *model.MailingListWebhookSubscription, 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(`list`).
|
|
As(`delivery`)
|
|
query := database.
|
|
Select(ctx, d).
|
|
From(`gql_list_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 *mailingListWebhookSubscriptionResolver) Sample(ctx context.Context, obj *model.MailingListWebhookSubscription, event model.WebhookEvent) (string, error) {
|
|
payloadUUID := uuid.New()
|
|
webhook := corewebhooks.WebhookContext{
|
|
User: auth.ForContext(ctx),
|
|
PayloadUUID: payloadUUID,
|
|
Name: "list",
|
|
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.WebhookEventListUpdated, model.WebhookEventListDeleted:
|
|
desc := "Sample mailing list for testing webhooks"
|
|
webhook.Payload = &model.MailingListEvent{
|
|
UUID: payloadUUID.String(),
|
|
Event: event,
|
|
Date: time.Now().UTC(),
|
|
List: &model.MailingList{
|
|
ID: -1,
|
|
Created: time.Now().UTC(),
|
|
Updated: time.Now().UTC(),
|
|
Name: "sample-list",
|
|
Description: &desc,
|
|
Visibility: model.VisibilityPublic,
|
|
|
|
OwnerID: auth.UserID,
|
|
RawPermitMime: "",
|
|
RawRejectMime: "",
|
|
Access: model.ACCESS_ALL,
|
|
DefaultAccess: model.ACCESS_ALL,
|
|
AccessID: nil,
|
|
SubscriptionID: nil,
|
|
},
|
|
}
|
|
case model.WebhookEventEmailReceived:
|
|
email := &model.Email{
|
|
ID: -1,
|
|
Received: time.Now().UTC(),
|
|
Body: "Sample email body\r\n",
|
|
Subject: "Sample email",
|
|
MessageID: "970701.32784@example.com",
|
|
InReplyTo: nil,
|
|
Patch: model.Patch{
|
|
Index: nil,
|
|
Count: nil,
|
|
Version: nil,
|
|
Prefix: nil,
|
|
Subject: nil,
|
|
},
|
|
MailingListID: -1,
|
|
PatchsetID: nil,
|
|
ThreadID: nil,
|
|
ParentID: nil,
|
|
SenderID: nil,
|
|
|
|
RawEnvelope: []byte("Mime-Version: 1.0\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/plain; charset=UTF-8\r\nSubject: Sample email\r\nFrom: <someone@example.com>\r\nTo: <sample-list@example.com>\r\nDate: Tue, 14 Jun 2022 09:31:03 +0000\r\nMessage-Id: <970701.32784@example.com>\r\n\r\nSample email body\r\n"),
|
|
RawHeader: mail.Header{},
|
|
}
|
|
email.Populate()
|
|
webhook.Payload = &model.EmailEvent{
|
|
UUID: payloadUUID.String(),
|
|
Event: event,
|
|
Date: time.Now().UTC(),
|
|
Email: email,
|
|
}
|
|
case model.WebhookEventPatchsetReceived:
|
|
webhook.Payload = &model.PatchsetEvent{
|
|
UUID: payloadUUID.String(),
|
|
Event: event,
|
|
Date: time.Now().UTC(),
|
|
Patchset: &model.Patchset{
|
|
ID: -1,
|
|
Created: time.Now().UTC(),
|
|
Updated: time.Now().UTC(),
|
|
Subject: "Sample patchset",
|
|
Prefix: nil,
|
|
Version: 1,
|
|
MailingListID: -1,
|
|
CoverLetterID: nil,
|
|
SupersededByID: nil,
|
|
RawStatus: "proposed",
|
|
},
|
|
}
|
|
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
|
|
}
|
|
|
|
func (r *mailingListWebhookSubscriptionResolver) List(ctx context.Context, obj *model.MailingListWebhookSubscription) (*model.MailingList, error) {
|
|
return loaders.ForContext(ctx).MailingListsByID.Load(obj.ListID)
|
|
}
|
|
|
|
func (r *mutationResolver) CreateMailingList(ctx context.Context, name string, description *string, visibility model.Visibility) (*model.MailingList, error) {
|
|
valid := valid.New(ctx)
|
|
valid.Expect(listNameRE.MatchString(name), "Name must match %s", listNameRE.String()).
|
|
WithField("name").
|
|
And(name != "." && name != ".." && name != ".git" && name != ".hg",
|
|
"This is a reserved name and cannot be used for user mailing lists.").
|
|
WithField("name")
|
|
valid.Expect(description == nil || len(*description) < 2048,
|
|
"Description must be fewer than 2048 characters").
|
|
WithField("description")
|
|
if !valid.Ok() {
|
|
return nil, nil
|
|
}
|
|
|
|
var list model.MailingList
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO list (
|
|
created, updated, name, description, visibility, owner_id
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
$1, $2, $3, $4
|
|
) RETURNING
|
|
id, created, updated, name, description, visibility, owner_id,
|
|
permit_mimetypes, reject_mimetypes, default_access;
|
|
`, name, description, visibility.String(), auth.ForContext(ctx).UserID)
|
|
|
|
if err := row.Scan(&list.ID, &list.Created, &list.Updated, &list.Name,
|
|
&list.Description, &list.Visibility, &list.OwnerID,
|
|
&list.RawPermitMime, &list.RawRejectMime,
|
|
&list.DefaultAccess); err != nil {
|
|
if err, ok := err.(*pq.Error); ok &&
|
|
err.Code == "23505" && // unique_violation
|
|
err.Constraint == "uq_list_owner_id_name" {
|
|
return fmt.Errorf("A mailing list with this name already exists.")
|
|
}
|
|
return err
|
|
}
|
|
list.Access = model.ACCESS_ALL
|
|
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO subscription (
|
|
created, updated, list_id, user_id
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
$1, $2
|
|
);
|
|
`, list.ID, auth.ForContext(ctx).UserID)
|
|
return err
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
webhooks.DeliverLegacyUserListEvent(ctx, &list, "list:create")
|
|
webhooks.DeliverUserMailingListEvent(ctx, model.WebhookEventListCreated, &list)
|
|
|
|
return &list, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UpdateMailingList(ctx context.Context, id int, input map[string]interface{}) (*model.MailingList, error) {
|
|
valid := valid.New(ctx).WithInput(input)
|
|
query := sq.Update(`list`).PlaceholderFormat(sq.Dollar)
|
|
|
|
valid.OptionalString("description", func(desc string) {
|
|
valid.Expect(len(desc) < 2048,
|
|
"Description must be fewer than 2048 characters").
|
|
WithField("description")
|
|
query = query.Set("description", desc)
|
|
})
|
|
valid.OptionalString("visibility", func(visibility string) {
|
|
query = query.Set("visibility", visibility)
|
|
})
|
|
mime := func(name string) {
|
|
valid.Optional(name+"Mime", func(object interface{}) {
|
|
list, ok := object.([]interface{})
|
|
if !ok {
|
|
panic("Invalid mime list") // GraphQL invariant
|
|
}
|
|
items := make([]string, len(list))
|
|
for i, item := range list {
|
|
str, ok := item.(string)
|
|
if !ok {
|
|
panic("Invalid mime list") // GraphQL invariant
|
|
}
|
|
items[i] = str
|
|
}
|
|
// TODO: This should be updated to a native Postgres array type
|
|
query = query.Set(name+"_mimetypes", strings.Join(items, ","))
|
|
})
|
|
}
|
|
mime("permit")
|
|
mime("reject")
|
|
if !valid.Ok() {
|
|
return nil, nil
|
|
}
|
|
|
|
var list model.MailingList
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := query.
|
|
Where(`list.id = ? AND list.owner_id = ?`,
|
|
id, auth.ForContext(ctx).UserID).
|
|
Suffix(`RETURNING
|
|
id, created, updated, name, description, visibility, owner_id,
|
|
permit_mimetypes, reject_mimetypes, default_access`).
|
|
RunWith(tx).
|
|
QueryRowContext(ctx)
|
|
|
|
if err := row.Scan(&list.ID, &list.Created, &list.Updated, &list.Name,
|
|
&list.Description, &list.Visibility, &list.OwnerID,
|
|
&list.RawPermitMime, &list.RawRejectMime,
|
|
&list.DefaultAccess); err != nil {
|
|
return err
|
|
}
|
|
list.Access = model.ACCESS_ALL
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
webhooks.DeliverLegacyListEvent(ctx, &list, "list:update")
|
|
webhooks.DeliverUserMailingListEvent(ctx, model.WebhookEventListUpdated, &list)
|
|
webhooks.DeliverMailingListEvent(ctx, model.WebhookEventListUpdated, &list)
|
|
|
|
return &list, nil
|
|
}
|
|
|
|
func (r *mutationResolver) DeleteMailingList(ctx context.Context, id int) (*model.MailingList, error) {
|
|
var list model.MailingList
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
// XXX: It would be nice if we generalized database.Scan a little bit
|
|
// so it can work with queries other than Select. Might call for
|
|
// forking squirrel to add PostgreSQL-specific features.
|
|
row := tx.QueryRowContext(ctx, `
|
|
DELETE FROM list
|
|
WHERE id = $1 AND owner_id = $2
|
|
RETURNING
|
|
id, created, updated, name, description, visibility, owner_id,
|
|
permit_mimetypes, reject_mimetypes, default_access;`,
|
|
id, auth.ForContext(ctx).UserID)
|
|
if err := row.Scan(&list.ID, &list.Created, &list.Updated, &list.Name,
|
|
&list.Description, &list.Visibility, &list.OwnerID,
|
|
&list.RawPermitMime, &list.RawRejectMime, &list.DefaultAccess); err != nil {
|
|
return err
|
|
}
|
|
list.Access = model.ACCESS_ALL
|
|
|
|
// We need to do this here so that it picks up the subscription list
|
|
// before the cascade sets their list_id columns to null.
|
|
webhooks.DeliverLegacyListEvent(ctx, &list, "list:delete")
|
|
webhooks.DeliverUserMailingListEvent(ctx, model.WebhookEventListDeleted, &list)
|
|
webhooks.DeliverMailingListEvent(ctx, model.WebhookEventListDeleted, &list)
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return &list, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UpdateUserACL(ctx context.Context, listID int, userID int, input model.ACLInput) (*model.MailingListACL, error) {
|
|
bits := ACLInputBits(input)
|
|
var acl model.MailingListACL
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO access (
|
|
created, updated, list_id, user_id, permissions
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
-- The purpose of this is to filter out lists that the user is
|
|
-- not an owner of. Saves us a round-trip
|
|
(SELECT id FROM list WHERE id = $1 AND owner_id = $4),
|
|
$2, $3
|
|
)
|
|
ON CONFLICT ON CONSTRAINT uq_access_list_id_user_id
|
|
DO UPDATE SET
|
|
updated = NOW() at time zone 'utc',
|
|
permissions = $3
|
|
RETURNING id, created, list_id, user_id, permissions;
|
|
`, listID, userID, bits, auth.ForContext(ctx).UserID)
|
|
if err := row.Scan(&acl.ID, &acl.Created, &acl.MailingListID,
|
|
&acl.UserID, &acl.RawAccess); err != nil {
|
|
if err, ok := err.(*pq.Error); ok &&
|
|
err.Code == "23502" && // not_null_violation
|
|
err.Column == "list_id" {
|
|
return sql.ErrNoRows
|
|
} else if ok &&
|
|
err.Code == "23503" && // foreign_key_violation
|
|
err.Constraint == "access_list_id_fkey" {
|
|
return sql.ErrNoRows
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &acl, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UpdateSenderACL(ctx context.Context, listID int, address string, input model.ACLInput) (*model.MailingListACL, error) {
|
|
bits := ACLInputBits(input)
|
|
var acl model.MailingListACL
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO access (
|
|
created, updated, list_id, email, permissions
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
-- The purpose of this is to filter out lists that the user is
|
|
-- not an owner of. Saves us a round-trip
|
|
(SELECT id FROM list WHERE id = $1 AND owner_id = $4),
|
|
$2, $3
|
|
)
|
|
ON CONFLICT ON CONSTRAINT uq_access_list_id_email
|
|
DO UPDATE SET
|
|
updated = NOW() at time zone 'utc',
|
|
permissions = $3
|
|
RETURNING id, created, list_id, email, permissions;
|
|
`, listID, address, bits, auth.ForContext(ctx).UserID)
|
|
if err := row.Scan(&acl.ID, &acl.Created, &acl.MailingListID,
|
|
&acl.Email, &acl.RawAccess); err != nil {
|
|
if err, ok := err.(*pq.Error); ok &&
|
|
err.Code == "23502" && // not_null_violation
|
|
err.Column == "list_id" {
|
|
return sql.ErrNoRows
|
|
} else if ok &&
|
|
err.Code == "23503" && // foreign_key_violation
|
|
err.Constraint == "access_list_id_fkey" {
|
|
return sql.ErrNoRows
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &acl, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UpdateMailingListACL(ctx context.Context, listID int, input model.ACLInput) (*model.MailingList, error) {
|
|
bits := ACLInputBits(input)
|
|
var list model.MailingList
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
UPDATE list SET default_access = $1
|
|
WHERE id = $2 AND owner_id = $3
|
|
RETURNING
|
|
id, created, updated, name, description, visibility, owner_id,
|
|
permit_mimetypes, reject_mimetypes, default_access;
|
|
`, bits, listID, auth.ForContext(ctx).UserID)
|
|
if err := row.Scan(&list.ID, &list.Created, &list.Updated, &list.Name,
|
|
&list.Description, &list.Visibility, &list.OwnerID,
|
|
&list.RawPermitMime, &list.RawRejectMime, &list.DefaultAccess); err != nil {
|
|
return err
|
|
}
|
|
list.Access = model.ACCESS_ALL
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
webhooks.DeliverLegacyListEvent(ctx, &list, "list:update")
|
|
webhooks.DeliverUserMailingListEvent(ctx, model.WebhookEventListUpdated, &list)
|
|
webhooks.DeliverMailingListEvent(ctx, model.WebhookEventListUpdated, &list)
|
|
return &list, nil
|
|
}
|
|
|
|
func (r *mutationResolver) DeleteACL(ctx context.Context, id int) (*model.MailingListACL, error) {
|
|
var acl model.MailingListACL
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
DELETE FROM access
|
|
USING list
|
|
WHERE
|
|
access.list_id = list.id AND
|
|
access.id = $1 AND
|
|
list.owner_id = $2
|
|
RETURNING
|
|
access.id, access.created, access.list_id, access.user_id,
|
|
access.email, access.permissions;
|
|
`, id, auth.ForContext(ctx).UserID)
|
|
if err := row.Scan(&acl.ID, &acl.Created, &acl.MailingListID,
|
|
&acl.UserID, &acl.Email, &acl.RawAccess); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &acl, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UpdatePatchset(ctx context.Context, id int, status model.PatchsetStatus) (*model.Patchset, error) {
|
|
var patchset model.Patchset
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
WITH lists AS (
|
|
SELECT id
|
|
FROM list
|
|
WHERE owner_id = $1
|
|
)
|
|
UPDATE patchset
|
|
SET status = $3
|
|
WHERE
|
|
list_id in (SELECT id FROM lists) AND
|
|
id = $2
|
|
RETURNING
|
|
id, created, updated, subject, prefix, version, status,
|
|
list_id, cover_letter_id, superseded_by_id;
|
|
`, auth.ForContext(ctx).UserID, id, strings.ToLower(status.String()))
|
|
if err := row.Scan(&patchset.ID, &patchset.Created, &patchset.Updated,
|
|
&patchset.Subject, &patchset.Prefix, &patchset.Version,
|
|
&patchset.RawStatus, &patchset.MailingListID,
|
|
&patchset.CoverLetterID, &patchset.SupersededByID); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &patchset, nil
|
|
}
|
|
|
|
func (r *mutationResolver) CreateTool(ctx context.Context, patchsetID int, details string, icon model.ToolIcon) (*model.PatchsetTool, error) {
|
|
var tool model.PatchsetTool
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
WITH patch AS (
|
|
SELECT patchset.id
|
|
FROM patchset
|
|
JOIN list ON list.id = patchset.list_id
|
|
LEFT JOIN access
|
|
ON access.user_id = $2 AND
|
|
access.list_id = list.id
|
|
WHERE patchset.id = $1 AND (
|
|
list.owner_id = $2 OR
|
|
(access.id IS NOT NULL AND access.permissions & $3 > 0))
|
|
)
|
|
INSERT INTO patchset_tool (
|
|
created, updated, patchset_id, key, icon, details
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
(SELECT id FROM patch), 'graphql',
|
|
$4, $5
|
|
)
|
|
RETURNING id, created, updated, icon, details, patchset_id;`,
|
|
patchsetID, auth.ForContext(ctx).UserID,
|
|
model.ACCESS_MODERATE, strings.ToLower(icon.String()), details)
|
|
return row.Scan(&tool.ID, &tool.Created, &tool.Updated,
|
|
&tool.RawIcon, &tool.Details, &tool.PatchsetID)
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &tool, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UpdateTool(ctx context.Context, id int, details *string, icon *model.ToolIcon) (*model.PatchsetTool, error) {
|
|
var tool model.PatchsetTool
|
|
query := sq.Update(`patchset_tool`).PlaceholderFormat(sq.Dollar)
|
|
if details != nil {
|
|
query = query.Set("details", *details)
|
|
}
|
|
if icon != nil {
|
|
query = query.Set("icon", strings.ToLower(icon.String()))
|
|
}
|
|
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
userID := auth.ForContext(ctx).UserID
|
|
row := query.
|
|
Prefix(`WITH patch AS (
|
|
SELECT tool.id
|
|
FROM patchset_tool tool
|
|
JOIN patchset ON patchset.id = tool.patchset_id
|
|
JOIN list ON list.id = patchset.list_id
|
|
LEFT JOIN access
|
|
ON access.user_id = ? AND
|
|
access.list_id = list.id
|
|
WHERE tool.id = ? AND (list.owner_id = ? OR
|
|
(access.id IS NOT NULL AND access.permissions & ? > 0))
|
|
)`, userID, id, userID, model.ACCESS_MODERATE).
|
|
Where(`patchset_tool.id = (SELECT id FROM patch)`).
|
|
Suffix(`RETURNING id, created, updated, icon, details, patchset_id`).
|
|
RunWith(tx).
|
|
QueryRowContext(ctx)
|
|
return row.Scan(&tool.ID, &tool.Created, &tool.Updated,
|
|
&tool.RawIcon, &tool.Details, &tool.PatchsetID)
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &tool, nil
|
|
}
|
|
|
|
func (r *mutationResolver) MailingListSubscribe(ctx context.Context, listID int) (*model.MailingListSubscription, error) {
|
|
var sub model.MailingListSubscription
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
WITH list AS (
|
|
SELECT list.id
|
|
FROM list
|
|
LEFT JOIN access ON access.user_id = $1 AND access.list_id = list.id
|
|
WHERE list.id = $2 AND (
|
|
list.owner_id = $1 OR
|
|
(access.id IS NOT NULL AND access.permissions & $3 > 0) OR
|
|
(access.id IS NULL AND list.default_access & $3 > 0)
|
|
)
|
|
) INSERT INTO subscription (
|
|
created, updated, user_id, list_id
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
$1, (SELECT id FROM list)
|
|
)
|
|
ON CONFLICT ON CONSTRAINT subscription_list_id_user_id_unique
|
|
DO UPDATE SET updated = NOW() at time zone 'utc'
|
|
RETURNING id, created, user_id, list_id;`,
|
|
auth.ForContext(ctx).UserID, listID, model.ACCESS_BROWSE)
|
|
if err := row.Scan(&sub.ID, &sub.Created, &sub.UserID, &sub.ListID); err != nil {
|
|
if err, ok := err.(*pq.Error); ok &&
|
|
err.Code == "23502" &&
|
|
err.Column == "list_id" { // not_null_violation
|
|
return sql.ErrNoRows
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &sub, nil
|
|
}
|
|
|
|
func (r *mutationResolver) MailingListUnsubscribe(ctx context.Context, listID int) (*model.MailingListSubscription, error) {
|
|
var sub model.MailingListSubscription
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
DELETE FROM subscription
|
|
WHERE list_id = $1 AND user_id = $2
|
|
RETURNING id, created, user_id, list_id;
|
|
`, listID, auth.ForContext(ctx).UserID)
|
|
return row.Scan(&sub.ID, &sub.Created, &sub.UserID, &sub.ListID)
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &sub, nil
|
|
}
|
|
|
|
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.WebhookEventListCreated, model.WebhookEventListUpdated,
|
|
model.WebhookEventListDeleted:
|
|
access = "LISTS"
|
|
case model.WebhookEventEmailReceived:
|
|
access = "EMAILS"
|
|
case model.WebhookEventPatchsetReceived:
|
|
access = "PATCHES"
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (r *mutationResolver) CreateMailingListWebhook(ctx context.Context, listID int, config model.MailingListWebhookInput) (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.MailingListWebhookSubscription
|
|
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.WebhookEventListUpdated, model.WebhookEventListDeleted:
|
|
access = "LISTS"
|
|
case model.WebhookEventEmailReceived:
|
|
access = "EMAILS"
|
|
case model.WebhookEventPatchsetReceived:
|
|
access = "PATCHES"
|
|
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")
|
|
}
|
|
|
|
list, err := loaders.ForContext(ctx).MailingListsByID.Load(listID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if list == nil {
|
|
return nil, errors.New("Access denied")
|
|
}
|
|
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO gql_list_wh_sub (
|
|
created, events, url, query,
|
|
auth_method,
|
|
token_hash, grants, client_id, expires,
|
|
node_id,
|
|
user_id,
|
|
list_id
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
|
|
) RETURNING id, url, query, events, user_id, list_id;`,
|
|
pq.Array(events), config.URL, config.Query,
|
|
ac.AuthMethod,
|
|
ac.TokenHash, ac.Grants, ac.ClientID, ac.Expires, // OAUTH2
|
|
ac.NodeID, // INTERNAL
|
|
user.UserID,
|
|
list.ID)
|
|
|
|
if err := row.Scan(&sub.ID, &sub.URL,
|
|
&sub.Query, pq.Array(&sub.Events), &sub.UserID, &sub.ListID); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &sub, nil
|
|
}
|
|
|
|
func (r *mutationResolver) DeleteMailingListWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
|
|
var sub model.MailingListWebhookSubscription
|
|
|
|
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_list_wh_sub`).
|
|
PlaceholderFormat(sq.Dollar).
|
|
Where(sq.And{sq.Expr(`id = ?`, id), filter}).
|
|
Suffix(`RETURNING id, url, query, events, user_id, list_id`).
|
|
RunWith(tx).
|
|
QueryRowContext(ctx)
|
|
if err := row.Scan(&sub.ID, &sub.URL,
|
|
&sub.Query, pq.Array(&sub.Events), &sub.UserID, &sub.ListID); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("No mailing list webhook by ID %d found for this user", id)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return &sub, nil
|
|
}
|
|
|
|
func (r *mutationResolver) TriggerUserEmailWebhooks(ctx context.Context, emailID int) (*model.Email, error) {
|
|
email, err := loaders.ForContext(ctx).EmailsByID.Load(emailID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverUserEmailEvent(ctx, model.WebhookEventEmailReceived, email)
|
|
return email, nil
|
|
}
|
|
|
|
func (r *mutationResolver) TriggerListEmailWebhooks(ctx context.Context, listID int, emailID int) (*model.Email, error) {
|
|
email, err := loaders.ForContext(ctx).EmailsByID.Load(emailID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverListEmailEvent(ctx, listID, model.WebhookEventEmailReceived, email)
|
|
if email.PatchsetID != nil {
|
|
patchset, err := loaders.ForContext(ctx).PatchsetsByID.Load(*email.PatchsetID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverListPatchsetEvent(ctx, listID, model.WebhookEventPatchsetReceived, patchset)
|
|
}
|
|
return email, nil
|
|
}
|
|
|
|
func (r *patchsetResolver) Submitter(ctx context.Context, obj *model.Patchset) (model.Entity, error) {
|
|
// XXX: It would be nice if we didn't have to fetch the thread details in
|
|
// order to get the patchset submitter. The database has a submitter field
|
|
// but it's not very useful.
|
|
var submitter model.Entity
|
|
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
var (
|
|
err error
|
|
envelope []byte
|
|
senderID *int
|
|
)
|
|
row := tx.QueryRowContext(ctx, `
|
|
SELECT envelope, sender_id
|
|
FROM email
|
|
WHERE
|
|
email.thread_id IS NULL AND
|
|
email.patchset_id = $1
|
|
`, obj.ID)
|
|
|
|
if err = row.Scan(&envelope, &senderID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if senderID != nil {
|
|
submitter, err = loaders.ForContext(ctx).UsersByID.Load(*senderID)
|
|
return err
|
|
}
|
|
|
|
reader, err := mail.CreateReader(bytes.NewBuffer(envelope))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer reader.Close()
|
|
|
|
list, err := reader.Header.AddressList("From")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(list) != 1 {
|
|
panic(fmt.Errorf("Malformed email %d, multiple senders", obj.ID))
|
|
}
|
|
|
|
submitter = &model.Mailbox{
|
|
Name: list[0].Name,
|
|
Address: list[0].Address,
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return submitter, nil
|
|
}
|
|
|
|
func (r *patchsetResolver) CoverLetter(ctx context.Context, obj *model.Patchset) (*model.Email, error) {
|
|
if obj.CoverLetterID == nil {
|
|
return nil, nil
|
|
}
|
|
return loaders.ForContext(ctx).EmailsByID.Load(*obj.CoverLetterID)
|
|
}
|
|
|
|
func (r *patchsetResolver) Thread(ctx context.Context, obj *model.Patchset) (*model.Thread, error) {
|
|
var thread model.Thread
|
|
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
// Note that no authentication is required here because anyone with
|
|
// access to the patchset also has access to the thread.
|
|
row := database.
|
|
Select(ctx, &thread).
|
|
From(`email thread`).
|
|
Where(`thread.patchset_id = ? AND thread.thread_id IS NULL`, obj.ID).
|
|
RunWith(tx).
|
|
QueryRowContext(ctx)
|
|
if err := row.Scan(database.Scan(ctx, &thread)...); err != nil {
|
|
return err
|
|
}
|
|
thread.Populate()
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &thread, nil
|
|
}
|
|
|
|
func (r *patchsetResolver) SupersededBy(ctx context.Context, obj *model.Patchset) (*model.Patchset, error) {
|
|
// TODO: This feature has not been completed
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *patchsetResolver) List(ctx context.Context, obj *model.Patchset) (*model.MailingList, error) {
|
|
return loaders.ForContext(ctx).MailingListsByID.Load(obj.MailingListID)
|
|
}
|
|
|
|
func (r *patchsetResolver) Patches(ctx context.Context, obj *model.Patchset, cursor *coremodel.Cursor) (*model.EmailCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var emails []*model.Email
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
email := (&model.Email{}).As(`email`)
|
|
query := database.
|
|
Select(ctx, email).
|
|
From(`email`).
|
|
Where(`email.patchset_id = ? AND email.is_patch`, obj.ID).
|
|
OrderBy("email.created")
|
|
emails, cursor = email.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.EmailCursor{emails, cursor}, nil
|
|
}
|
|
|
|
func (r *patchsetResolver) Tools(ctx context.Context, obj *model.Patchset) ([]*model.PatchsetTool, error) {
|
|
var tools []*model.PatchsetTool
|
|
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
// No authentication required because anyone who has access to the
|
|
// patchset also has access to the tools.
|
|
tool := (&model.PatchsetTool{}).As(`tool`)
|
|
rows, err := database.
|
|
Select(ctx, tool).
|
|
From(`patchset_tool tool`).
|
|
Where(`tool.patchset_id = ?`, obj.ID).
|
|
RunWith(tx).
|
|
QueryContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for rows.Next() {
|
|
var tool model.PatchsetTool
|
|
if err := rows.Scan(database.Scan(ctx, &tool)...); err != nil {
|
|
return err
|
|
}
|
|
tools = append(tools, &tool)
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return tools, nil
|
|
}
|
|
|
|
func (r *patchsetResolver) Mbox(ctx context.Context, obj *model.Patchset) (*model.URL, error) {
|
|
origin := config.GetOrigin(config.ForContext(ctx), "lists.sr.ht", true)
|
|
uri := fmt.Sprintf("%s/query/patchset/%d.mbox", origin, obj.ID)
|
|
url, err := url.Parse(uri)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &model.URL{url}, nil
|
|
}
|
|
|
|
func (r *patchsetToolResolver) Patchset(ctx context.Context, obj *model.PatchsetTool) (*model.Patchset, error) {
|
|
return loaders.ForContext(ctx).PatchsetsByID.Load(obj.PatchsetID)
|
|
}
|
|
|
|
func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
|
|
return &model.Version{
|
|
Major: 0,
|
|
Minor: 0,
|
|
Patch: 0,
|
|
DeprecationDate: nil,
|
|
}, nil
|
|
}
|
|
|
|
func (r *queryResolver) Me(ctx context.Context) (*model.User, error) {
|
|
user := auth.ForContext(ctx)
|
|
return &model.User{
|
|
ID: user.UserID,
|
|
Created: user.Created,
|
|
Updated: user.Updated,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
URL: user.URL,
|
|
Location: user.Location,
|
|
Bio: user.Bio,
|
|
}, nil
|
|
}
|
|
|
|
func (r *queryResolver) User(ctx context.Context, id int) (*model.User, error) {
|
|
return loaders.ForContext(ctx).UsersByID.Load(id)
|
|
}
|
|
|
|
func (r *queryResolver) UserByName(ctx context.Context, username string) (*model.User, error) {
|
|
return loaders.ForContext(ctx).UsersByName.Load(username)
|
|
}
|
|
|
|
func (r *queryResolver) MailingList(ctx context.Context, id int) (*model.MailingList, error) {
|
|
return loaders.ForContext(ctx).MailingListsByID.Load(id)
|
|
}
|
|
|
|
func (r *queryResolver) MailingListByName(ctx context.Context, name string) (*model.MailingList, error) {
|
|
return loaders.ForContext(ctx).MailingListsByName.Load(name)
|
|
}
|
|
|
|
func (r *queryResolver) MailingListByOwner(ctx context.Context, ownerName string, listName string) (*model.MailingList, error) {
|
|
if strings.HasPrefix(ownerName, "~") {
|
|
ownerName = ownerName[1:]
|
|
} else {
|
|
return nil, fmt.Errorf("Expected owner to be a canonical name")
|
|
}
|
|
return loaders.ForContext(ctx).MailingListsByOwnerName.Load([2]string{ownerName, listName})
|
|
}
|
|
|
|
func (r *queryResolver) Email(ctx context.Context, id int) (*model.Email, error) {
|
|
return loaders.ForContext(ctx).EmailsByID.Load(id)
|
|
}
|
|
|
|
func (r *queryResolver) Message(ctx context.Context, messageID string) (*model.Email, error) {
|
|
return loaders.ForContext(ctx).EmailsByMessageID.Load(messageID)
|
|
}
|
|
|
|
func (r *queryResolver) Patchset(ctx context.Context, id int) (*model.Patchset, error) {
|
|
return loaders.ForContext(ctx).PatchsetsByID.Load(id)
|
|
}
|
|
|
|
func (r *queryResolver) MailingLists(ctx context.Context, cursor *coremodel.Cursor) (*model.MailingListCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var lists []*model.MailingList
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
list := (&model.MailingList{})
|
|
query := database.
|
|
Select(ctx, list).
|
|
From(`list`).
|
|
Where(`list.owner_id = ?`, auth.ForContext(ctx).UserID)
|
|
lists, cursor = list.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.MailingListCursor{lists, cursor}, nil
|
|
}
|
|
|
|
func (r *queryResolver) Subscriptions(ctx context.Context, cursor *coremodel.Cursor) (*model.ActivitySubscriptionCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var subs []model.ActivitySubscription
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
sub := (&model.MailingListSubscription{}).As(`sub`)
|
|
query := database.
|
|
Select(ctx, sub).
|
|
From(`subscription sub`).
|
|
Where(`sub.user_id = ?`, auth.ForContext(ctx).UserID)
|
|
subs, cursor = sub.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.ActivitySubscriptionCursor{subs, cursor}, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (r *threadResolver) Sender(ctx context.Context, obj *model.Thread) (model.Entity, error) {
|
|
if obj.SenderID != nil {
|
|
return loaders.ForContext(ctx).UsersByID.Load(*obj.SenderID)
|
|
}
|
|
list, err := obj.RawHeader.AddressList("From")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(list) != 1 {
|
|
panic(fmt.Errorf("Malformed email %d, multiple senders", obj.ID))
|
|
}
|
|
return &model.Mailbox{
|
|
Name: list[0].Name,
|
|
Address: list[0].Address,
|
|
}, nil
|
|
}
|
|
|
|
func (r *threadResolver) Root(ctx context.Context, obj *model.Thread) (*model.Email, error) {
|
|
return loaders.ForContext(ctx).EmailsByID.Load(obj.ID)
|
|
}
|
|
|
|
func (r *threadResolver) List(ctx context.Context, obj *model.Thread) (*model.MailingList, error) {
|
|
return loaders.ForContext(ctx).MailingListsByID.Load(obj.MailingListID)
|
|
}
|
|
|
|
func (r *threadResolver) Descendants(ctx context.Context, obj *model.Thread, cursor *coremodel.Cursor) (*model.EmailCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var emails []*model.Email
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
email := (&model.Email{}).As(`email`)
|
|
query := database.
|
|
Select(ctx, email).
|
|
From(`email`).
|
|
Where(`email.thread_id = ?`, obj.ID).
|
|
OrderBy("email.created")
|
|
emails, cursor = email.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.EmailCursor{emails, cursor}, nil
|
|
}
|
|
|
|
func (r *threadResolver) Mailto(ctx context.Context, obj *model.Thread) (string, error) {
|
|
var (
|
|
header mail.Header
|
|
ownerName string
|
|
listName string
|
|
)
|
|
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
var envelope []byte
|
|
row := tx.QueryRowContext(ctx, `
|
|
SELECT envelope, "user".username, list.name
|
|
FROM email
|
|
JOIN list ON list.id = email.list_id
|
|
JOIN "user" ON "user".id = list.owner_id
|
|
WHERE email.thread_id = $1
|
|
ORDER BY email.created DESC
|
|
LIMIT 1;
|
|
`, obj.ID)
|
|
|
|
if err := row.Scan(&envelope, &ownerName, &listName); err != nil {
|
|
return err
|
|
}
|
|
|
|
reader, err := mail.CreateReader(bytes.NewBuffer(envelope))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
header = reader.Header
|
|
reader.Close()
|
|
return nil
|
|
}); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
v := url.Values{}
|
|
|
|
if subject, err := header.Subject(); err != nil {
|
|
panic(err)
|
|
} else {
|
|
if !strings.HasPrefix(subject, "Re: ") {
|
|
subject = "Re: " + subject
|
|
}
|
|
v.Set("subject", subject)
|
|
}
|
|
|
|
if id, err := header.MessageID(); err != nil {
|
|
panic(err)
|
|
} else {
|
|
v.Set("in-reply-to", id)
|
|
}
|
|
|
|
postTo, ok := config.ForContext(ctx).Get("lists.sr.ht", "posting-domain")
|
|
if !ok {
|
|
panic("No posting domain configured")
|
|
}
|
|
|
|
url := url.URL{
|
|
Scheme: "mailto",
|
|
User: url.User(fmt.Sprintf("~%s/%s", ownerName, listName)),
|
|
Host: postTo,
|
|
RawQuery: v.Encode(),
|
|
}
|
|
|
|
return url.String(), nil
|
|
}
|
|
|
|
func (r *threadResolver) Mbox(ctx context.Context, obj *model.Thread) (*model.URL, error) {
|
|
origin := config.GetOrigin(config.ForContext(ctx), "lists.sr.ht", true)
|
|
uri := fmt.Sprintf("%s/query/thread/%d.mbox", origin, obj.ID)
|
|
url, err := url.Parse(uri)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &model.URL{url}, nil
|
|
}
|
|
|
|
func (r *threadResolver) Blocks(ctx context.Context, obj *model.Thread) ([]*model.ThreadBlock, error) {
|
|
var (
|
|
messages []emailthreads.Message
|
|
emails []model.Email
|
|
)
|
|
err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
query := database.
|
|
Select(ctx, new(model.Email).As(`email`)).
|
|
From(`email`).
|
|
Where(`email.thread_id = ?`, obj.ID).
|
|
OrderBy(`email.created`)
|
|
|
|
rows, err := query.RunWith(tx).QueryContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var email model.Email
|
|
if err := rows.Scan(database.Scan(ctx, &email)...); err != nil {
|
|
return err
|
|
}
|
|
email.Populate()
|
|
|
|
mr, err := mail.CreateReader(bytes.NewReader(email.RawEnvelope))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create mail reader: %v", err)
|
|
}
|
|
header := mr.Header
|
|
text, err := getMailText(mr)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get mail text: %v", err)
|
|
}
|
|
mr.Close()
|
|
|
|
messages = append(messages, emailthreads.Message{
|
|
Header: header,
|
|
Body: text,
|
|
})
|
|
emails = append(emails, email)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if len(messages) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
root, err := emailthreads.Parse(messages)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse thread: %v", err)
|
|
}
|
|
|
|
sources := make(map[*emailthreads.Message]*model.Email)
|
|
for i := range messages {
|
|
sources[&messages[i]] = &emails[i]
|
|
}
|
|
|
|
var blocks []*model.ThreadBlock
|
|
indexes := make(map[*emailthreads.Block]int)
|
|
toThreadBlockList(&blocks, root, nil, sources, indexes)
|
|
|
|
return blocks, nil
|
|
}
|
|
|
|
func (r *userResolver) Lists(ctx context.Context, obj *model.User, cursor *coremodel.Cursor) (*model.MailingListCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var lists []*model.MailingList
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
list := (&model.MailingList{}).As(`list`)
|
|
user := auth.ForContext(ctx)
|
|
query := database.
|
|
Select(ctx, list).
|
|
From(`list`).
|
|
Where(sq.And{
|
|
sq.Expr(`list.owner_id = ?`, obj.ID),
|
|
sq.Or{
|
|
sq.Expr(`list.owner_id = ?`, user.UserID),
|
|
sq.Expr(`list.visibility != 'PRIVATE'`),
|
|
sq.Expr(`access.permissions > 0`),
|
|
},
|
|
})
|
|
lists, cursor = list.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.MailingListCursor{lists, cursor}, nil
|
|
}
|
|
|
|
func (r *userResolver) Emails(ctx context.Context, obj *model.User, cursor *coremodel.Cursor) (*model.EmailCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var emails []*model.Email
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
user := auth.ForContext(ctx)
|
|
email := (&model.Email{}).As(`mail`)
|
|
query := database.
|
|
Select(ctx, email).
|
|
From(`email mail`).
|
|
Join(`list ON mail.list_id = list.id`).
|
|
LeftJoin(`access ON
|
|
access.list_id = list.id AND
|
|
access.user_id = ?`, user.UserID).
|
|
Where(sq.And{
|
|
sq.Expr(`mail.sender_id = ?`, obj.ID),
|
|
sq.Or{
|
|
sq.Expr(`list.owner_id = ?`, user.UserID),
|
|
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
|
|
sq.Expr(`list.default_access & ? > 0`, model.ACCESS_BROWSE),
|
|
},
|
|
})
|
|
emails, cursor = email.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.EmailCursor{emails, cursor}, nil
|
|
}
|
|
|
|
func (r *userResolver) Threads(ctx context.Context, obj *model.User, cursor *coremodel.Cursor) (*model.ThreadCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var threads []*model.Thread
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
user := auth.ForContext(ctx)
|
|
thread := (&model.Thread{}).As(`mail`)
|
|
query := database.
|
|
Select(ctx, thread).
|
|
From(`email mail`).
|
|
Join(`list ON mail.list_id = list.id`).
|
|
LeftJoin(`access ON
|
|
access.list_id = list.id AND
|
|
access.user_id = ?`, user.UserID).
|
|
Where(sq.And{
|
|
sq.Expr(`mail.sender_id = ?`, obj.ID),
|
|
sq.Expr(`mail.thread_id IS NULL`),
|
|
sq.Or{
|
|
sq.Expr(`list.owner_id = ?`, user.UserID),
|
|
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
|
|
sq.Expr(`list.default_access & ? > 0`, model.ACCESS_BROWSE),
|
|
},
|
|
})
|
|
threads, cursor = thread.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.ThreadCursor{threads, cursor}, nil
|
|
}
|
|
|
|
func (r *userResolver) Patches(ctx context.Context, obj *model.User, cursor *coremodel.Cursor) (*model.PatchsetCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var patches []*model.Patchset
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
user := auth.ForContext(ctx)
|
|
patch := (&model.Patchset{}).As(`patch`)
|
|
query := database.
|
|
Select(ctx, patch).
|
|
From(`patchset patch`).
|
|
Join(`list ON patch.list_id = list.id`).
|
|
Join(`email ON email.patchset_id = patch.id AND email.thread_id IS NULL`).
|
|
LeftJoin(`access ON
|
|
access.list_id = list.id AND
|
|
access.user_id = ?`, user.UserID).
|
|
Where(sq.And{
|
|
sq.Expr(`email.sender_id = ?`, obj.ID),
|
|
sq.Or{
|
|
sq.Expr(`list.owner_id = ?`, user.UserID),
|
|
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
|
|
sq.Expr(`list.default_access & ? > 0`, model.ACCESS_BROWSE),
|
|
},
|
|
})
|
|
patches, cursor = patch.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.PatchsetCursor{patches, cursor}, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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.WebhookEventListCreated, model.WebhookEventListUpdated,
|
|
model.WebhookEventListDeleted:
|
|
desc := "Sample mailing list for testing webhooks"
|
|
webhook.Payload = &model.MailingListEvent{
|
|
UUID: payloadUUID.String(),
|
|
Event: event,
|
|
Date: time.Now().UTC(),
|
|
List: &model.MailingList{
|
|
ID: -1,
|
|
Created: time.Now().UTC(),
|
|
Updated: time.Now().UTC(),
|
|
Name: "sample-list",
|
|
Description: &desc,
|
|
Visibility: model.VisibilityPublic,
|
|
|
|
OwnerID: auth.UserID,
|
|
RawPermitMime: "",
|
|
RawRejectMime: "",
|
|
Access: model.ACCESS_ALL,
|
|
DefaultAccess: model.ACCESS_ALL,
|
|
AccessID: nil,
|
|
SubscriptionID: nil,
|
|
},
|
|
}
|
|
case model.WebhookEventEmailReceived:
|
|
email := &model.Email{
|
|
ID: -1,
|
|
Received: time.Now().UTC(),
|
|
Body: "Sample email body\r\n",
|
|
Subject: "Sample email",
|
|
MessageID: "970701.32784@example.com",
|
|
InReplyTo: nil,
|
|
Patch: model.Patch{
|
|
Index: nil,
|
|
Count: nil,
|
|
Version: nil,
|
|
Prefix: nil,
|
|
Subject: nil,
|
|
},
|
|
MailingListID: -1,
|
|
PatchsetID: nil,
|
|
ThreadID: nil,
|
|
ParentID: nil,
|
|
SenderID: nil,
|
|
|
|
RawEnvelope: []byte("Mime-Version: 1.0\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/plain; charset=UTF-8\r\nSubject: Sample email\r\nFrom: <someone@example.com>\r\nTo: <sample-list@example.com>\r\nDate: Tue, 14 Jun 2022 09:31:03 +0000\r\nMessage-Id: <970701.32784@example.com>\r\n\r\nSample email body\r\n"),
|
|
RawHeader: mail.Header{},
|
|
}
|
|
email.Populate()
|
|
webhook.Payload = &model.EmailEvent{
|
|
UUID: payloadUUID.String(),
|
|
Event: event,
|
|
Date: time.Now().UTC(),
|
|
Email: email,
|
|
}
|
|
case model.WebhookEventPatchsetReceived:
|
|
webhook.Payload = &model.PatchsetEvent{
|
|
UUID: payloadUUID.String(),
|
|
Event: event,
|
|
Date: time.Now().UTC(),
|
|
Patchset: &model.Patchset{
|
|
ID: -1,
|
|
Created: time.Now().UTC(),
|
|
Updated: time.Now().UTC(),
|
|
Subject: "Sample patchset",
|
|
Prefix: nil,
|
|
Version: 1,
|
|
MailingListID: -1,
|
|
CoverLetterID: nil,
|
|
SupersededByID: nil,
|
|
RawStatus: "proposed",
|
|
},
|
|
}
|
|
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
|
|
}
|
|
|
|
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`)
|
|
case "list":
|
|
subscription = (&model.MailingListWebhookSubscription{}).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
|
|
}
|
|
|
|
// Email returns api.EmailResolver implementation.
|
|
func (r *Resolver) Email() api.EmailResolver { return &emailResolver{r} }
|
|
|
|
// MailingList returns api.MailingListResolver implementation.
|
|
func (r *Resolver) MailingList() api.MailingListResolver { return &mailingListResolver{r} }
|
|
|
|
// MailingListACL returns api.MailingListACLResolver implementation.
|
|
func (r *Resolver) MailingListACL() api.MailingListACLResolver { return &mailingListACLResolver{r} }
|
|
|
|
// MailingListSubscription returns api.MailingListSubscriptionResolver implementation.
|
|
func (r *Resolver) MailingListSubscription() api.MailingListSubscriptionResolver {
|
|
return &mailingListSubscriptionResolver{r}
|
|
}
|
|
|
|
// MailingListWebhookSubscription returns api.MailingListWebhookSubscriptionResolver implementation.
|
|
func (r *Resolver) MailingListWebhookSubscription() api.MailingListWebhookSubscriptionResolver {
|
|
return &mailingListWebhookSubscriptionResolver{r}
|
|
}
|
|
|
|
// Mutation returns api.MutationResolver implementation.
|
|
func (r *Resolver) Mutation() api.MutationResolver { return &mutationResolver{r} }
|
|
|
|
// Patchset returns api.PatchsetResolver implementation.
|
|
func (r *Resolver) Patchset() api.PatchsetResolver { return &patchsetResolver{r} }
|
|
|
|
// PatchsetTool returns api.PatchsetToolResolver implementation.
|
|
func (r *Resolver) PatchsetTool() api.PatchsetToolResolver { return &patchsetToolResolver{r} }
|
|
|
|
// Query returns api.QueryResolver implementation.
|
|
func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} }
|
|
|
|
// Thread returns api.ThreadResolver implementation.
|
|
func (r *Resolver) Thread() api.ThreadResolver { return &threadResolver{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 emailResolver struct{ *Resolver }
|
|
type mailingListResolver struct{ *Resolver }
|
|
type mailingListACLResolver struct{ *Resolver }
|
|
type mailingListSubscriptionResolver struct{ *Resolver }
|
|
type mailingListWebhookSubscriptionResolver struct{ *Resolver }
|
|
type mutationResolver struct{ *Resolver }
|
|
type patchsetResolver struct{ *Resolver }
|
|
type patchsetToolResolver struct{ *Resolver }
|
|
type queryResolver struct{ *Resolver }
|
|
type threadResolver struct{ *Resolver }
|
|
type userResolver struct{ *Resolver }
|
|
type userWebhookSubscriptionResolver struct{ *Resolver }
|
|
type webhookDeliveryResolver struct{ *Resolver }
|