~bigbes/core-go

1d2a30cb44dc685860d9efe764e0ac2d50ab4c5d — Drew DeVault 5 years ago b0ebfa5
Import GQL server interfaces from gql.sr.ht
A auth/middleware.go => auth/middleware.go +736 -0
@@ 0,0 1,736 @@
package auth

import (
	"context"
	"crypto/sha512"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net"
	"net/http"
	"regexp"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/vaughan0/go-ini"
	"github.com/vektah/gqlparser/gqlerror"

	"git.sr.ht/~sircmpwn/core-go/client"
	"git.sr.ht/~sircmpwn/core-go/config"
	"git.sr.ht/~sircmpwn/core-go/crypto"
	"git.sr.ht/~sircmpwn/core-go/database"
)

var userCtxKey = &contextKey{"user"}

type contextKey struct {
	name string
}

var (
	oauthBearerRegex  = regexp.MustCompile(`^[0-9a-f]{32}$`)
	oauth2BearerRegex = regexp.MustCompile(`^[0-9a-zA-Z_+/]{33,}$`)
)

const (
	USER_UNCONFIRMED       = "unconfirmed"
	USER_ACTIVE_NON_PAYING = "active_non_paying"
	USER_ACTIVE_FREE       = "active_free"
	USER_ACTIVE_PAYING     = "active_paying"
	USER_ACTIVE_DELINQUENT = "active_delinquent"
	USER_ADMIN             = "admin"
	USER_UNKNOWN           = "unknown"
	USER_SUSPENDED         = "suspended"
)

const (
	AUTH_OAUTH_LEGACY = iota
	AUTH_OAUTH2       = iota
	AUTH_COOKIE       = iota
	AUTH_INTERNAL     = iota
)

type AuthContext struct {
	UserID           int
	Created          time.Time
	Updated          time.Time
	Username         string
	Email            string
	UserType         string
	URL              *string
	Location         *string
	Bio              *string
	SuspensionNotice *string
	AuthMethod       int

	// Only filled out if AuthMethod == AUTH_INTERNAL
	InternalAuth InternalAuth

	// Only filled out if AuthMethod == AUTH_OAUTH2
	OAuth2Token *OAuth2Token
	Access      map[string]string
}

func authError(w http.ResponseWriter, reason string, code int) {
	gqlerr := gqlerror.Errorf("Authentication error: %s", reason)
	b, err := json.Marshal(gqlerr)
	if err != nil {
		panic(err)
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	w.Write(b)
}

func authForUsername(ctx context.Context, username string) (*AuthContext, error) {
	var auth AuthContext
	if err := database.WithTx(ctx, &sql.TxOptions{
		Isolation: 0,
		ReadOnly: true,
	}, func(tx *sql.Tx) error {
		var (
			err  error
			rows *sql.Rows
		)
		query := database.
			Select(ctx, []string{
				`u.id`, `u.username`,
				`u.created`, `u.updated`,
				`u.email`,
				`u.user_type`,
				`u.url`, `u.location`, `u.bio`,
				`u.suspension_notice`,
			}).
			From(`"user" u`).
			Where(`u.username = ?`, username)
		if rows, err = query.RunWith(tx).Query(); err != nil {
			panic(err)
		}
		defer rows.Close()

		if !rows.Next() {
			if err := rows.Err(); err != nil {
				panic(err)
			}
			return fmt.Errorf("Authenticating for unknown user %s", username)
		}
		if err := rows.Scan(&auth.UserID, &auth.Username, &auth.Created,
			&auth.Updated, &auth.Email, &auth.UserType, &auth.URL, &auth.Location,
			&auth.Bio, &auth.SuspensionNotice); err != nil {
			panic(err)
		}
		if rows.Next() {
			// TODO: Fetch user info from meta if necessary
			if err := rows.Err(); err != nil {
				panic(err)
			}
			panic(errors.New("Multiple matching user accounts; invariant broken"))
		}
		return nil
	}); err != nil {
		return nil, err
	}

	if auth.UserType == USER_SUSPENDED {
		return nil, fmt.Errorf(
			"Account suspended with the following notice: %s\nContact support",
			auth.SuspensionNotice)
	}

	return &auth, nil
}

func authForOAuthClient(ctx context.Context, clientUUID string) (*AuthContext, error) {
	var auth AuthContext
	if err := database.WithTx(ctx, &sql.TxOptions{
		Isolation: 0,
		ReadOnly: true,
	}, func(tx *sql.Tx) error {
		var (
			err  error
			rows *sql.Rows
		)
		query := database.
			Select(ctx, []string{
				`u.id`, `u.username`,
				`u.created`, `u.updated`,
				`u.email`,
				`u.user_type`,
				`u.url`, `u.location`, `u.bio`,
				`u.suspension_notice`,
			}).
			From(`"oauth2_client" client`).
			Join(`"user" u ON u.id = client.owner_id`).
			Where(`client.client_uuid = ?`, clientUUID)
		if rows, err = query.RunWith(tx).Query(); err != nil {
			panic(err)
		}
		defer rows.Close()

		if !rows.Next() {
			if err := rows.Err(); err != nil {
				panic(err)
			}
			return fmt.Errorf("Authenticating for unknown client ID %s", clientUUID)
		}
		if err := rows.Scan(&auth.UserID, &auth.Username, &auth.Created,
			&auth.Updated, &auth.Email, &auth.UserType, &auth.URL, &auth.Location,
			&auth.Bio, &auth.SuspensionNotice); err != nil {
			panic(err)
		}
		if rows.Next() {
			// TODO: Fetch user info from meta if necessary
			if err := rows.Err(); err != nil {
				panic(err)
			}
			panic(errors.New("Multiple matching user accounts; invariant broken"))
		}
		return nil
	}); err != nil {
		return nil, err
	}

	if auth.UserType == USER_SUSPENDED {
		return nil, fmt.Errorf(
			"Account suspended with the following notice: %s\nContact support",
			auth.SuspensionNotice)
	}

	return &auth, nil
}

type AuthCookie struct {
	// The username of the authenticated user
	Name string `json:"name"`
}

func cookieAuth(cookie *http.Cookie, w http.ResponseWriter,
	r *http.Request, next http.Handler) {
	payload := crypto.Decrypt([]byte(cookie.Value))
	if payload == nil {
		authError(w, "Invalid authentication cookie", http.StatusForbidden)
		return
	}

	var authCookie AuthCookie
	if err := json.Unmarshal(payload, &authCookie); err != nil {
		panic(err) // Programmer error
	}

	auth, err := authForUsername(r.Context(), authCookie.Name)
	if err != nil {
		authError(w, err.Error(), http.StatusForbidden)
		return
	}

	auth.AuthMethod = AUTH_COOKIE

	ctx := context.WithValue(r.Context(), userCtxKey, auth)
	r = r.WithContext(ctx)
	next.ServeHTTP(w, r)
}

type InternalAuth struct {
	// The username of the authenticated user
	Name string `json:"name"`

	// An arbitrary identifier for this internal user, e.g. "git.sr.ht"
	ClientID string `json:"client_id"`

	// An arbitrary identifier for this internal node, e.g. "us-east-3.git.sr.ht"
	NodeID string `json:"node_id"`

	// Only used by specific meta.sr.ht routes
	OAuthClientUUID string `json:"oauth_client_id",omit-empty`
}

func internalAuth(internalNet []*net.IPNet, payload []byte,
	w http.ResponseWriter, r *http.Request, next http.Handler) {
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		host = r.RemoteAddr
	}
	ip := net.ParseIP(host)
	if ip == nil {
		panic(fmt.Errorf("Unable to parse remote address"))
	}
	var ok bool = false
	for _, ipnet := range internalNet {
		ok = ok || ipnet.Contains(ip)
		if ok {
			break
		}
	}
	if !ok {
		authError(w, "Invalid source IP for internal auth", http.StatusForbidden)
		return
	}

	payload = crypto.DecryptWithExpiration(payload, 30*time.Second)
	if payload == nil {
		authError(w, "Invalid Authorization header", http.StatusForbidden)
		return
	}

	var internalAuth InternalAuth
	if err := json.Unmarshal(payload, &internalAuth); err != nil {
		panic(err) // Programmer error
	}

	if internalAuth.ClientID == "" || internalAuth.NodeID == "" {
		authError(w, "Invalid Authorization header", http.StatusForbidden)
	}

	var auth *AuthContext
	if internalAuth.OAuthClientUUID != "" {
		auth, err = authForOAuthClient(r.Context(), internalAuth.OAuthClientUUID)
	} else {
		auth, err = authForUsername(r.Context(), internalAuth.Name)
	}
	if err != nil {
		authError(w, err.Error(), http.StatusForbidden)
		return
	}

	auth.AuthMethod = AUTH_INTERNAL
	auth.InternalAuth = internalAuth

	ctx := context.WithValue(r.Context(), userCtxKey, auth)
	r = r.WithContext(ctx)
	next.ServeHTTP(w, r)
}

func FetchMetaProfile(ctx context.Context, username string, user *AuthContext) error {
	if config.ServiceName(ctx) == "meta.sr.ht" {
		panic(errors.New("Cannot fetch profile from ourselves"))
	}

	type GraphQLProfile struct {
		ID               int     `json:"id"`
		Username         string  `json:"username"`
		Email            string  `json:"email"`
		URL              *string `json:"url"`
		Location         *string `json:"location"`
		Bio              *string `json:"bio"`
		UserType         string  `json:"userType"`
		SuspensionNotice string  `json:"suspensionNotice"`
	}

	type GraphQLResponse struct {
		Data struct {
			Me GraphQLProfile `json:"me"`
		} `json:"data"`
	}

	query := client.GraphQLQuery{
		Query: `
			query {
				me {
					id
					username
					email
					url
					location
					bio
					userType
				}
			}`,
	}

	var result GraphQLResponse
	if err := client.Execute(ctx, username,
		"meta.sr.ht", query, &result); err != nil {
		return err
	}

	profile := result.Data.Me
	return database.WithTx(ctx, nil, func(tx *sql.Tx) error {
		row := tx.QueryRowContext(ctx, `
			INSERT INTO "user" (
				created,
				updated,
				username,
				email,
				user_type,
				url,
				location,
				bio,
				suspension_notice
			)
			VALUES (
				NOW() at time zone 'utc',
				NOW() at time zone 'utc',
				$1, $2, $3, $4, $5, $6, $7
			)
			ON CONFLICT DO NOTHING
			RETURNING (
				id,
				created,
				updated,
				username,
				email,
				user_type,
				url,
				location,
				bio,
				suspension_notice
			);`,
			profile.Username, profile.Email, profile.UserType, profile.URL,
			profile.Location, profile.Bio, profile.SuspensionNotice)

		// TODO: Register webhooks
		if err := row.Scan(&user.UserID, user.Created, user.Updated,
			&user.Username, &user.Email, &user.UserType, &user.URL,
			&user.Location, &user.Bio, &user.SuspensionNotice); err != nil {
			if err == sql.ErrNoRows {
				panic(errors.New("Failed to upsert user record from meta.sr.ht"))
			}
			return err
		}
		return nil
	})
}

func LookupUser(ctx context.Context, username string, user *AuthContext) error {
	return database.WithTx(ctx, &sql.TxOptions{
		Isolation: 0,
		ReadOnly: true,
	}, func(tx *sql.Tx) error {
		var (
			err  error
			rows *sql.Rows
		)
		query := database.
			Select(ctx, []string{
				`u.id`, `u.username`,
				`u.created`, `u.updated`,
				`u.email`,
				`u.user_type`,
				`u.url`, `u.location`, `u.bio`,
				`u.suspension_notice`,
			}).
			From(`"user" u`).
			Where(`u.username = ?`, username)
		if rows, err = query.RunWith(tx).Query(); err != nil {
			return err
		}
		defer rows.Close()

		if !rows.Next() {
			if err = rows.Err(); err != nil {
				return err
			}
			return FetchMetaProfile(ctx, username, user)
		}
		if err = rows.Scan(
			&user.UserID, &user.Username,
			&user.Created, &user.Updated,
			&user.Email,
			&user.UserType,
			&user.URL,
			&user.Location,
			&user.Bio,
			&user.SuspensionNotice); err != nil {
			return err
		}
		if rows.Next() {
			if err = rows.Err(); err != nil {
				return err
			}
			panic(errors.New("Multiple users of the same username; invariant broken"))
		}
		return nil
	})
}

// Returns true if this token or client ID has been revoked (and therefore
// should not be trusted)
func LookupTokenRevocation(ctx context.Context,
	username string, hash [64]byte, clientID string) (bool, error) {
	type GraphQLResponse struct {
		Data struct {
			RevocationStatus bool `json:"tokenRevocationStatus"`
		} `json:"data"`
	}

	query := client.GraphQLQuery{
		Query: `
			query RevocationStatus($hash: String!, $clientId: String) {
				tokenRevocationStatus(hash: $hash, clientId: $clientId)
			}`,
		Variables: map[string]interface{}{
			"hash":     hex.EncodeToString(hash[:]),
			"clientId": clientID,
		},
	}

	var result GraphQLResponse
	if err := client.Execute(ctx, username,
		"meta.sr.ht", query, &result); err != nil {
		return true, err
	}
	return result.Data.RevocationStatus, nil
}

func OAuth2(token string, hash [64]byte, w http.ResponseWriter,
	r *http.Request, next http.Handler) {
	var (
		auth    AuthContext
		err     error
		res     int32
		tempErr int32
		wg      sync.WaitGroup
	)
	wg.Add(2)

	ot := DecodeToken(token)
	if ot == nil {
		authError(w, `Invalid or expired OAuth 2.0 bearer token`, http.StatusForbidden)
		return
	}

	go func() {
		defer wg.Done()
		err = LookupUser(r.Context(), ot.Username, &auth)
		if err != nil {
			log.Printf("LookupUser: %e", err)
			atomic.AddInt32(&tempErr, 1)
		} else {
			atomic.AddInt32(&res, 1)
		}
	}()

	go func() {
		defer wg.Done()
		isRevoked, err := LookupTokenRevocation(r.Context(),
			ot.Username, hash, ot.ClientID)
		if err != nil {
			log.Printf("LookupTokenRevocation: %e", err)
			atomic.AddInt32(&tempErr, 1)
		} else if !isRevoked {
			atomic.AddInt32(&res, 1)
		}
	}()

	wg.Wait()
	if res != 2 {
		if tempErr != 0 {
			authError(w, "Temporary error; try again later", http.StatusInternalServerError)
		} else {
			authError(w, "Invalid or expired OAuth 2.0 bearer token", http.StatusForbidden)
		}
		return
	}

	if auth.UserType == USER_SUSPENDED {
		authError(w, fmt.Sprintf(
			"Account suspended with the following notice: %s\nContact support",
			auth.SuspensionNotice), http.StatusForbidden)
		return
	}

	auth.AuthMethod = AUTH_OAUTH2
	auth.OAuth2Token = ot

	if ot.Grants != "" {
		auth.Access = make(map[string]string)
		for _, grant := range strings.Split(ot.Grants, " ") {
			var (
				service string
				scope   string
				access  string
			)
			parts := strings.Split(grant, "/")
			if len(parts) != 2 {
				panic(fmt.Errorf("OAuth grant '%s' without service/scope format", grant))
			}
			service = parts[0]
			parts = strings.Split(parts[1], ":")
			scope = parts[0]
			if len(parts) == 1 {
				access = "RO"
			} else {
				access = parts[1]
			}
			if service == config.ServiceName(r.Context()) {
				auth.Access[scope] = access
			}
		}
	}

	ctx := context.WithValue(r.Context(), userCtxKey, &auth)
	r = r.WithContext(ctx)
	next.ServeHTTP(w, r)
}

// TODO: Remove legacy OAuth support
func LegacyOAuth(bearer string, hash [64]byte, w http.ResponseWriter,
	r *http.Request, next http.Handler) {
	var (
		auth    AuthContext
		expires time.Time
		scopes  string
	)
	if err := database.WithTx(r.Context(), &sql.TxOptions{
		Isolation: 0,
		ReadOnly: true,
	}, func(tx *sql.Tx) error {
		var (
			err  error
			rows *sql.Rows
		)
		query := database.
			Select(r.Context(), []string{
				`ot.expires`,
				`ot.scopes`,
				`u.id`, `u.username`,
				`u.created`, `u.updated`,
				`u.email`,
				`u.user_type`,
				`u.url`, `u.location`, `u.bio`,
				`u.suspension_notice`,
			}).
			From(`oauthtoken ot`).
			Join(`"user" u ON u.id = ot.user_id`).
			Where(`ot.token_hash = ?`, bearer)
		if rows, err = query.RunWith(tx).Query(); err != nil {
			panic(err)
		}
		defer rows.Close()

		if !rows.Next() {
			if err := rows.Err(); err != nil {
				panic(err)
			}
			authError(w, "Invalid or expired OAuth token", http.StatusForbidden)
			return nil
		}
		if err := rows.Scan(&expires, &scopes,
			&auth.UserID, &auth.Username,
			&auth.Created, &auth.Updated,
			&auth.Email,
			&auth.UserType,
			&auth.URL,
			&auth.Location,
			&auth.Bio,
			&auth.SuspensionNotice); err != nil {
			panic(err)
		}
		if rows.Next() {
			if err := rows.Err(); err != nil {
				panic(err)
			}
			panic(errors.New("Multiple matching OAuth tokens; invariant broken"))
		}
		return nil
	}); err != nil {
		panic(err)
	}

	if time.Now().UTC().After(expires) {
		authError(w, "Invalid or expired OAuth token", http.StatusForbidden)
		return
	}

	if auth.UserType == USER_SUSPENDED {
		authError(w, fmt.Sprintf(
			"Account suspended with the following notice: %s\nContact support",
			auth.SuspensionNotice), http.StatusForbidden)
		return
	}

	if scopes != "*" {
		authError(w, "Presently, OAuth authentication to the GraphQL API is only supported for OAuth tokens with all permissions, namely '*'.", http.StatusForbidden)
		return
	}

	auth.AuthMethod = AUTH_OAUTH_LEGACY

	ctx := context.WithValue(r.Context(), userCtxKey, &auth)
	r = r.WithContext(ctx)
	next.ServeHTTP(w, r)
}

func Middleware(conf ini.File, apiconf string) func(http.Handler) http.Handler {
	var internalNet []*net.IPNet
	src, ok := conf.Get(apiconf, "internal-ipnet")
	if !ok {
		// Conservative default
		src = "127.0.0.1/24,::1/64"
	}
	for _, cidr := range strings.Split(src, ",") {
		_, ipnet, err := net.ParseCIDR(cidr)
		if err != nil {
			panic(err)
		}
		internalNet = append(internalNet, ipnet)
	}

	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if r.URL.Path != "/query" {
				next.ServeHTTP(w, r)
				return
			}

			cookie, err := r.Cookie("sr.ht.unified-login.v1")
			if err == nil {
				cookieAuth(cookie, w, r, next)
				return
			}

			auth := r.Header.Get("Authorization")
			if auth == "" {
				authError(w, `Authorization header is required. Expected 'Authorization: Bearer <token>'`, http.StatusForbidden)
				return
			}

			z := strings.SplitN(auth, " ", 2)
			if len(z) != 2 {
				authError(w, "Invalid Authorization header", http.StatusBadRequest)
				return
			}

			var bearer string
			switch z[0] {
			case "Bearer":
				token := []byte(z[1])
				if oauth2BearerRegex.Match(token) {
					hash := sha512.Sum512(token)
					bearer = z[1]
					OAuth2(bearer, hash, w, r, next)
					return
				}
				if oauthBearerRegex.Match(token) {
					hash := sha512.Sum512(token)
					bearer = hex.EncodeToString(hash[:])
					LegacyOAuth(bearer, hash, w, r, next)
					return
				}
				authError(w, "Invalid OAuth bearer token", http.StatusBadRequest)
				return
			case "Internal":
				payload := []byte(z[1])
				internalAuth(internalNet, payload, w, r, next)
				return
			default:
				authError(w, "Invalid Authorization header", http.StatusBadRequest)
				return
			}

		})
	}
}

func ForContext(ctx context.Context) *AuthContext {
	raw, ok := ctx.Value(userCtxKey).(*AuthContext)
	if !ok {
		panic(errors.New("Invalid authentication context"))
	}
	return raw
}

A auth/token.go => auth/token.go +77 -0
@@ 0,0 1,77 @@
package auth

import (
	"encoding/base64"
	"encoding/hex"
	"log"
	"time"

	"git.sr.ht/~sircmpwn/go-bare"

	"git.sr.ht/~sircmpwn/core-go/crypto"
)

const TokenVersion uint = 0

type Timestamp int64

func (t Timestamp) Time() time.Time {
	return time.Unix(int64(t), 0)
}

func ToTimestamp(t time.Time) Timestamp {
	return Timestamp(t.Unix())
}

type OAuth2Token struct {
	Version  uint
	Expires  Timestamp
	Grants   string
	ClientID string
	Username string
}

func (ot *OAuth2Token) Encode() string {
	plain, err := bare.Marshal(ot)
	if err != nil {
		panic(err)
	}
	mac := crypto.HMAC(plain)
	return base64.RawStdEncoding.EncodeToString(append(plain, mac...))
}

func DecodeToken(token string) *OAuth2Token {
	payload, err := base64.RawStdEncoding.DecodeString(token)
	if err != nil {
		log.Printf("Invalid bearer token: invalid base64 %e", err)
		return nil
	}
	if len(payload) <= 32 {
		log.Printf("Invalid bearer token: payload <32 bytes")
		return nil
	}

	mac := payload[len(payload)-32:]
	payload = payload[:len(payload)-32]
	if crypto.HMACVerify(payload, mac) == false {
		log.Printf("Invalid bearer token: HMAC verification failed (MAC: [%d]%s; payload: [%d]%s)",
			len(mac), hex.EncodeToString(mac), len(payload), hex.EncodeToString(payload))
		return nil
	}

	var ot OAuth2Token
	err = bare.Unmarshal(payload, &ot)
	if err != nil {
		log.Printf("Invalid bearer token: BARE unmarshal failed: %e", err)
		return nil
	}
	if ot.Version != TokenVersion {
		log.Printf("Invalid bearer token: invalid token version")
		return nil
	}
	if time.Now().UTC().After(ot.Expires.Time()) {
		log.Printf("Invalid bearer token: token expired")
		return nil
	}
	return &ot
}

A config/middleware.go => config/middleware.go +44 -0
@@ 0,0 1,44 @@
package config

import (
	"context"
	"errors"
	"net/http"

	"github.com/vaughan0/go-ini"
)

var configCtxKey = &contextKey{"config"}
var serviceCtxKey = &contextKey{"name"}

type contextKey struct {
	name string
}

func Middleware(conf ini.File, service string) func(next http.Handler) http.Handler {
	svc := service
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := context.WithValue(r.Context(), configCtxKey, conf)
			ctx = context.WithValue(ctx, serviceCtxKey, &svc)
			r = r.WithContext(ctx)
			next.ServeHTTP(w, r)
		})
	}
}

func ForContext(ctx context.Context) ini.File {
	raw, ok := ctx.Value(configCtxKey).(ini.File)
	if !ok {
		panic(errors.New("Invalid config context"))
	}
	return raw
}

func ServiceName(ctx context.Context) string {
	raw, ok := ctx.Value(serviceCtxKey).(*string)
	if !ok {
		panic(errors.New("Invalid config context"))
	}
	return *raw
}

A database/middleware.go => database/middleware.go +53 -0
@@ 0,0 1,53 @@
package database

import (
	"context"
	"database/sql"
	"errors"
	"net/http"
)

var dbCtxKey = &contextKey{"database"}

type contextKey struct {
	name string
}

func Middleware(db *sql.DB) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := context.WithValue(r.Context(), dbCtxKey, db)

			r = r.WithContext(ctx)
			next.ServeHTTP(w, r)
		})
	}
}

func ForContext(ctx context.Context) (*sql.Conn, error) {
	raw, ok := ctx.Value(dbCtxKey).(*sql.DB)
	if !ok {
		panic(errors.New("Invalid database context"))
	}
	return raw.Conn(ctx)
}

func WithTx(ctx context.Context, opts *sql.TxOptions, fn func(tx *sql.Tx) error) error {
	conn, err := ForContext(ctx)
	if err != nil {
		return err
	}
	defer conn.Close()
	tx, err := conn.BeginTx(ctx, opts)
	if err != nil {
		return err
	}
	defer func() {
		if r := recover(); r != nil {
			tx.Rollback()
			panic(r)
		}
		tx.Commit()
	}()
	return fn(tx)
}

A database/ql.go => database/ql.go +96 -0
@@ 0,0 1,96 @@
package database

import (
	"context"
	"sort"

	"github.com/lib/pq"
	"github.com/vektah/gqlparser/v2/ast"

	"github.com/99designs/gqlgen/graphql"
)

func collectFields(ctx context.Context) []graphql.CollectedField {
	var fields []graphql.CollectedField
	if graphql.GetFieldContext(ctx) != nil {
		fields = graphql.CollectFieldsCtx(ctx, nil)

		octx := graphql.GetOperationContext(ctx)
		for _, col := range fields {
			if col.Name == "results" {
				// This endpoint is using the cursor pattern; the columns we
				// actually need to filter with are nested into the results
				// field.
				fields = graphql.CollectFields(octx, col.SelectionSet, nil)
				break
			}
		}
	}
	return fields
}

func Scan(ctx context.Context, m Model) []interface{} {
	qlFields := collectFields(ctx)
	if len(qlFields) == 0 {
		// Collect all fields if we are not in an active graphql context
		for _, field := range m.Fields().All() {
			qlFields = append(qlFields, graphql.CollectedField{
				&ast.Field{Name: field.GQL}, nil,
			})
		}
	}

	sort.Slice(qlFields, func(a, b int) bool {
		return qlFields[a].Name < qlFields[b].Name
	})

	var fields []interface{}
	for _, qlField := range qlFields {
		if field, ok := m.Fields().GQL(qlField.Name); ok {
			fields = append(fields, field.Ptr)
		}
	}

	for _, field := range m.Fields().Anonymous() {
		fields = append(fields, field.Ptr)
	}

	return fields
}

func Columns(ctx context.Context, m Model) []string {
	fields := collectFields(ctx)
	if len(fields) == 0 {
		// Collect all fields if we are not in an active graphql context
		for _, field := range m.Fields().All() {
			fields = append(fields, graphql.CollectedField{
				&ast.Field{Name: field.GQL}, nil,
			})
		}
	}

	sort.Slice(fields, func(a, b int) bool {
		return fields[a].Name < fields[b].Name
	})

	var columns []string
	for _, gql := range fields {
		if field, ok := m.Fields().GQL(gql.Name); ok {
			columns = append(columns, WithAlias(m.Alias(), field.SQL))
		}
	}

	for _, field := range m.Fields().Anonymous() {
		columns = append(columns, WithAlias(m.Alias(), field.SQL))
	}

	return columns
}

func WithAlias(alias, col string) string {
	if alias != "" {
		return pq.QuoteIdentifier(alias) + "." + pq.QuoteIdentifier(col)
	} else {
		return pq.QuoteIdentifier(col)
	}
}

A database/sq.go => database/sq.go +142 -0
@@ 0,0 1,142 @@
package database

import (
	"context"
	"fmt"
	"reflect"

	sq "github.com/Masterminds/squirrel"
)

// Provides a mapping between PostgreSQL columns, GQL fields, and Go struct
// fields for all of the data associated with a model.
type FieldMap struct {
	SQL string
	GQL string
	Ptr interface{}
}

type ModelFields struct {
	Fields []*FieldMap

	byGQL map[string]*FieldMap
	bySQL map[string]*FieldMap
	anon  []*FieldMap
}

func (mf *ModelFields) buildCache() {
	if mf.byGQL != nil && mf.bySQL != nil {
		return
	}

	mf.byGQL = make(map[string]*FieldMap)
	mf.bySQL = make(map[string]*FieldMap)
	for _, f := range mf.Fields {
		if f.GQL != "" {
			mf.byGQL[f.GQL] = f
		} else {
			mf.anon = append(mf.anon, f)
		}
		mf.bySQL[f.SQL] = f
	}
}

func (mf *ModelFields) GQL(name string) (*FieldMap, bool) {
	mf.buildCache()
	if f, ok := mf.byGQL[name]; !ok {
		return nil, false
	} else {
		return f, true
	}
}

func (mf *ModelFields) SQL(name string) (*FieldMap, bool) {
	mf.buildCache()
	if f, ok := mf.bySQL[name]; !ok {
		return nil, false
	} else {
		return f, true
	}
}

func (mf *ModelFields) All() []*FieldMap {
	return mf.Fields
}

func (mf *ModelFields) Anonymous() []*FieldMap {
	mf.buildCache()
	return mf.anon
}

type Model interface {
	Alias()  string
	Fields() *ModelFields
	Table()  string
}

func Select(ctx context.Context, cols ...interface{}) sq.SelectBuilder {
	q := sq.Select().PlaceholderFormat(sq.Dollar)
	for _, col := range cols {
		switch col := col.(type) {
		case string:
			q = q.Columns(col)
		case []string:
			q = q.Columns(col...)
		case Model:
			q = q.Columns(Columns(ctx, col)...)
		default:
			panic(fmt.Errorf("Unknown selectable type %T", col))
		}
	}
	return q
}

// Prepares an UPDATE statement which applies the changes in the input map to
// the given model.
func Apply(m Model, input map[string]interface{}) sq.UpdateBuilder {
	// XXX: This relies on the GraphQL validator to prevent the user from
	// updating columns they're not supposed to. Risky?
	table := m.Table()
	if m.Alias() != "" {
		table += " " + m.Alias()
	}
	update := sq.Update(table).PlaceholderFormat(sq.Dollar)

	defer func() {
		// Some weird reflection errors don't get properly logged if they're
		// caught at a higher level.
		if err := recover(); err != nil {
			fmt.Printf("%v\n", err)
			panic(err)
		}
	}()

	for field, value := range input {
		f, ok := m.Fields().GQL(field)
		if !ok {
			continue
		}

		var (
			pv reflect.Value = reflect.Indirect(reflect.ValueOf(f.Ptr))
			rv reflect.Value = reflect.ValueOf(value)
		)
		if pv.Type().Kind() == reflect.Ptr {
			if !rv.IsValid() {
				pv.Set(reflect.Zero(pv.Type()))
				update = update.Set(WithAlias(m.Alias(), f.SQL), nil)
			} else {
				if !pv.Elem().IsValid() {
					pv.Set(reflect.New(pv.Type().Elem()))
				}
				reflect.Indirect(pv).Set(reflect.Indirect(rv))
				update = update.Set(WithAlias(m.Alias(), f.SQL),
					reflect.Indirect(rv).Interface())
			}
		} else {
			panic(fmt.Errorf("TODO"))
		}
	}

	return update
}

M go.mod => go.mod +13 -0
@@ 3,8 3,21 @@ module git.sr.ht/~sircmpwn/core-go
go 1.13

require (
	git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3
	git.sr.ht/~sircmpwn/go-bare v0.0.0-20200812160916-d2c72e1a5018
	git.sr.ht/~sircmpwn/gql.sr.ht v0.0.0-20200924184215-6a1a8f1031f3
	github.com/99designs/gqlgen v0.13.0
	github.com/Masterminds/squirrel v1.4.0
	github.com/fernet/fernet-go v0.0.0-20191111064656-eff2850e6001
	github.com/go-chi/chi v4.1.2+incompatible
	github.com/go-redis/redis/v8 v8.2.3
	github.com/lib/pq v1.8.0
	github.com/martinlindhe/base36 v1.1.0
	github.com/prometheus/client_golang v1.7.1
	github.com/stretchr/testify v1.6.1
	github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec
	github.com/vektah/gqlparser v1.3.1
	github.com/vektah/gqlparser/v2 v2.1.0
	golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de
	gopkg.in/mail.v2 v2.3.1
)

M go.sum => go.sum +227 -0
@@ 1,21 1,248 @@
git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3 h1:4wDp4BKF7NQqoh73VXpZsB/t1OEhDpz/zEpmdQfbjDk=
git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw=
git.sr.ht/~sircmpwn/go-bare v0.0.0-20200812160916-d2c72e1a5018 h1:89QMorzx6ML69PKPoayL3HuSfb7WqAlxD1dZ7DyzD0k=
git.sr.ht/~sircmpwn/go-bare v0.0.0-20200812160916-d2c72e1a5018/go.mod h1:BVJwbDfVjCjoFiKrhkei6NdGcZYpkDkdyCdg1ukytRA=
git.sr.ht/~sircmpwn/gql.sr.ht v0.0.0-20200924184215-6a1a8f1031f3 h1:En+681WkaSUkSVZ3xPG9/CcFr4cle79io3wB4Rrzjxw=
git.sr.ht/~sircmpwn/gql.sr.ht v0.0.0-20200924184215-6a1a8f1031f3/go.mod h1:V38DHc2+k1GmbNT4pE6OH/2fOXYcZHn9o/PUAEXH1P0=
github.com/99designs/gqlgen v0.11.4-0.20200512031635-40570d1b4d70/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/squirrel v1.4.0 h1:he5i/EXixZxrBUWcxzDYMiju9WZ3ld/l7QBNuo/eN3w=
github.com/Masterminds/squirrel v1.4.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/agnivade/levenshtein v1.0.3 h1:M5ZnqLOoZR8ygVq0FfkXsNOKzMCk0xRiow0R5+5VkQ0=
github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/fernet/fernet-go v0.0.0-20191111064656-eff2850e6001 h1:/UMxx5lGDg30aioUL9e7xJnbJfJeX7vhcm57fa5udaI=
github.com/fernet/fernet-go v0.0.0-20191111064656-eff2850e6001/go.mod h1:2H9hjfbpSMHwY503FclkV/lZTBh2YlOmLLSda12uL8c=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-chi/chi v1.0.0 h1:s/kv1cTXfivYjdKJdyUzNGyAWZ/2t7duW1gKn5ivu+c=
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.1.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
github.com/go-redis/redis/v8 v8.2.3 h1:eNesND+DWt/sjQOtPFxAbQkTIXaXX00qNLxjVWkZ70k=
github.com/go-redis/redis/v8 v8.2.3/go.mod h1:ysgGY09J/QeDYbu3HikWEIPCwaeOkuNoTgKayTEaEOw=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lib/pq v1.5.2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.8.0 h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
github.com/martinlindhe/base36 v1.1.0 h1:cIwvvwYse/0+1CkUPYH5ZvVIYG3JrILmQEIbLuar02Y=
github.com/martinlindhe/base36 v1.1.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/matryer/moq v0.0.0-20200310130814-7721994d1b54/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.3.0 h1:iDwIio/3gk2QtLLEsqU5lInaMzos0hDTz8a6lazSFVw=
github.com/mitchellh/mapstructure v1.3.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.6.0/go.mod h1:ZLOG9ck3JLRdB5MgO8f+lLTe83AXG6ro35rLTxvnIl4=
github.com/prometheus/client_golang v1.7.1 h1:NTGy1Ja9pByO+xAeH/qiWnLrKtr3hJPNjaVUwnjpdpA=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/common v0.10.0 h1:RyRA7RzGXQZiW+tGMr7sxa85G1z0yOpM1qq5c8lNawc=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.1.3 h1:F0+tqvhOksq22sc6iCHF5WGlWjdwj92p0udFh1VFBS8=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec h1:DGmKwyZwEB8dI7tbLt/I/gQuP559o/0FrAkHKlQM/Ks=
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec/go.mod h1:owBmyHYMLkxyrugmfwE/DLJyW8Ro9mkphwuVErQ0iUw=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/dataloaden v0.3.0/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser v1.3.1 h1:8b0IcD3qZKWJQHSzynbDlrtP3IxVydZ2DZepCGofqfU=
github.com/vektah/gqlparser v1.3.1/go.mod h1:bkVf0FX+Stjg/MHnm8mEyubuaArhNEqfQhF+OTiAL74=
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
github.com/vektah/gqlparser/v2 v2.1.0 h1:uiKJ+T5HMGGQM2kRKQ8Pxw8+Zq9qhhZhz/lieYvCMns=
github.com/vektah/gqlparser/v2 v2.1.0/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/otel v0.11.0 h1:IN2tzQa9Gc4ZVKnTaMbPVcHjvzOdg5n9QfnmlqiET7E=
go.opentelemetry.io/otel v0.11.0/go.mod h1:G8UCk+KooF2HLkgo8RHX9epABH/aRGYET7gQOqBVdB0=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de h1:ikNHVSjEfnvz6sxdSPCaPt572qowuyMDMJLLm3Db3ig=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1 h1:ogLJMz+qpzav7lGMh10LMvAkM/fAoGlaiiHYiFYdm80=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200519015757-0d0afa43d58a/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
sourcegraph.com/sourcegraph/appdash v0.0.0-20180110180208-2cc67fd64755/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k=

A server/directives.go => server/directives.go +51 -0
@@ 0,0 1,51 @@
package server

import (
	"context"
	"fmt"

	"github.com/99designs/gqlgen/graphql"

	"git.sr.ht/~sircmpwn/core-go/auth"
)

func Internal(ctx context.Context, obj interface{},
	next graphql.Resolver) (interface{}, error) {

	if auth.ForContext(ctx).AuthMethod != auth.AUTH_INTERNAL {
		return nil, fmt.Errorf("Access denied")
	}

	return next(ctx)
}

func Access(ctx context.Context, obj interface{}, next graphql.Resolver,
	scope string, kind string) (interface{}, error) {

	authctx := auth.ForContext(ctx)

	switch authctx.AuthMethod {
	case auth.AUTH_INTERNAL:
	case auth.AUTH_COOKIE:
		return next(ctx)
	case auth.AUTH_OAUTH_LEGACY:
		if kind == "RO" {
			// Only legacy tokens with "*" scopes ever get this far
			return next(ctx)
		}
	case auth.AUTH_OAUTH2:
		if authctx.Access == nil {
			return next(ctx)
		}
		if access, ok := authctx.Access[scope]; !ok {
			break
		} else if access == "RO" && kind == "RW" {
			break
		}
		return next(ctx)
	default:
		panic(fmt.Errorf("Unknown auth method for access check"))
	}

	return nil, fmt.Errorf("Access denied")
}

A server/email.go => server/email.go +128 -0
@@ 0,0 1,128 @@
package server

import (
	"bytes"
	"context"
	"crypto/rand"
	"encoding/binary"
	"errors"
	"fmt"
	"log"
	"net/mail"
	"os"
	"runtime"
	"strconv"
	"time"

	"github.com/99designs/gqlgen/graphql"
	"github.com/martinlindhe/base36"
	"github.com/vaughan0/go-ini"
	gomail "gopkg.in/mail.v2"

	"git.sr.ht/~sircmpwn/core-go/auth"
)

// Provides a graphql.RecoverFunc which will print the stack trace, and if
// debug mode is not enabled, email it to the administrator.
func EmailRecover(config ini.File, debug bool, srv string) graphql.RecoverFunc {
	return func(ctx context.Context, _origErr interface{}) error {
		var (
			ok      bool
			origErr error
		)
		if origErr, ok = _origErr.(error); !ok {
			log.Printf("Unexpected error in recover: %v\n", origErr)
			return fmt.Errorf("internal system error")
		}

		if errors.Is(origErr, context.Canceled) {
			return origErr
		}

		if errors.Is(origErr, context.DeadlineExceeded) {
			return origErr
		}

		if origErr.Error() == "pq: canceling statement due to user request" {
			return origErr
		}

		stack := make([]byte, 32768) // 32 KiB
		i := runtime.Stack(stack, false)
		log.Println(origErr.Error())
		log.Println(string(stack[:i]))
		if debug {
			return fmt.Errorf("internal system error")
		}

		to, ok := config.Get("mail", "error-to")
		if !ok {
			return fmt.Errorf("internal system error")
		}
		from, _ := config.Get("mail", "error-from")
		portStr, ok := config.Get("mail", "smtp-port")
		if !ok {
			return fmt.Errorf("internal system error")
		}
		port, _ := strconv.Atoi(portStr)
		host, _ := config.Get("mail", "smtp-host")
		user, _ := config.Get("mail", "smtp-user")
		pass, _ := config.Get("mail", "smtp-password")

		m := gomail.NewMessage()
		sender, err := mail.ParseAddress(from)
		if err != nil {
			log.Fatalf("Failed to parse sender address")
		}
		m.SetAddressHeader("From", sender.Address, sender.Name)
		recipient, err := mail.ParseAddress(to)
		if err != nil {
			log.Fatalf("Failed to parse recipient address")
		}
		m.SetAddressHeader("To", recipient.Address, recipient.Name)
		m.SetHeader("Message-ID", generateMessageID())
		m.SetHeader("Subject", fmt.Sprintf(
			"[%s] GraphQL query error: %v", srv, origErr))

		quser := auth.ForContext(ctx)
		octx := graphql.GetOperationContext(ctx)

		m.SetBody("text/plain", fmt.Sprintf(`Error occured processing GraphQL request:

%v

When running the following query on behalf of %s <%s>:

%s

The following stack trace was produced:

%s`, origErr, quser.Username, quser.Email, octx.RawQuery, string(stack[:i])))

		d := gomail.NewDialer(host, port, user, pass)
		if err := d.DialAndSend(m); err != nil {
			log.Printf("Error sending email: %v\n", err)
		}
		return fmt.Errorf("internal system error")
	}
}

// Generates an RFC 2822-compliant Message-Id based on the informational draft
// "Recommendations for generating Message IDs", for lack of a better
// authoritative source.
func generateMessageID() string {
	var (
		now   bytes.Buffer
		nonce []byte = make([]byte, 8)
	)
	binary.Write(&now, binary.BigEndian, time.Now().UnixNano())
	rand.Read(nonce)
	hostname, err := os.Hostname()
	if err != nil {
		hostname = "localhost"
	}
	return fmt.Sprintf("<%s.%s@%s>",
		base36.EncodeBytes(now.Bytes()),
		base36.EncodeBytes(nonce),
		hostname)
}

A server/server.go => server/server.go +173 -0
@@ 0,0 1,173 @@
package server

import (
	"database/sql"
	"fmt"
	"log"
	"net/http"
	"os"
	"strconv"
	"time"

	"git.sr.ht/~sircmpwn/getopt"
	"github.com/99designs/gqlgen/graphql"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/99designs/gqlgen/handler"
	"github.com/go-chi/chi"
	"github.com/go-chi/chi/middleware"
	goRedis "github.com/go-redis/redis/v8"
	_ "github.com/lib/pq"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"
	"github.com/prometheus/client_golang/prometheus/promhttp"
	"github.com/vaughan0/go-ini"

	"git.sr.ht/~sircmpwn/core-go/auth"
	"git.sr.ht/~sircmpwn/core-go/config"
	"git.sr.ht/~sircmpwn/core-go/crypto"
	"git.sr.ht/~sircmpwn/core-go/database"
	"git.sr.ht/~sircmpwn/core-go/redis"
)

var (
	requestsProcessed = promauto.NewCounter(prometheus.CounterOpts{
		Name: "api_requests_processed_total",
		Help: "Total number of API requests processed",
	})
	requestDuration = promauto.NewHistogram(prometheus.HistogramOpts{
		Name:    "api_request_duration_millis",
		Help:    "Duration of processed HTTP requests in milliseconds",
		Buckets: []float64{10, 20, 40, 80, 120, 300, 600, 900, 1800},
	})
)

var (
	debug bool
	addr  string
)

// Loads the application configuration, reads options from the command line,
// and initializes some internals based on these results.
func LoadConfig(defaultAddr string) ini.File {
	addr = defaultAddr
	var (
		config ini.File
		err    error
	)
	opts, _, err := getopt.Getopts(os.Args, "b:d")
	if err != nil {
		panic(err)
	}

	for _, opt := range opts {
		switch opt.Option {
		case 'b':
			addr = opt.Value
		case 'd':
			debug = true
		}
	}

	for _, path := range []string{"../config.ini", "/etc/sr.ht/config.ini"} {
		config, err = ini.LoadFile(path)
		if err == nil {
			break
		}
	}
	if err != nil {
		log.Fatalf("Failed to load config file: %v", err)
	}

	crypto.InitCrypto(config)
	return config
}

// Prepares a router with the sr.ht API middleware pre-configured. This
// connects to PostgreSQL to rig up the database middlewares.
func MakeRouter(service string, conf ini.File, schema graphql.ExecutableSchema,
	middlewares ...func(http.Handler) http.Handler) chi.Router {

	pgcs, ok := conf.Get(service, "connection-string")
	if !ok {
		log.Fatalf("No connection string provided in config.ini")
	}

	db, err := sql.Open("postgres", pgcs)
	if err != nil {
		log.Fatalf("Failed to open a database connection: %v", err)
	}

	rcs, ok := conf.Get("sr.ht", "redis-host")
	if !ok {
		rcs = "redis://"
	}
	ropts, err := goRedis.ParseURL(rcs)
	if err != nil {
		log.Fatalf("Invalid sr.ht::redis-host in config.ini: %e", err)
	}
	rc := goRedis.NewClient(ropts)

	apiconf := fmt.Sprintf("%s::api", service)

	var timeout time.Duration
	if to, ok := conf.Get(apiconf, "max-duration"); ok {
		timeout, err = time.ParseDuration(to)
		if err != nil {
			panic(err)
		}
	} else {
		timeout = 3 * time.Second
	}

	router := chi.NewRouter()
	router.Use(func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			next.ServeHTTP(w, r)
			end := time.Now()
			elapsed := end.Sub(start)
			requestDuration.Observe(float64(elapsed.Milliseconds()))
			requestsProcessed.Inc()
		})
	})
	router.Use(config.Middleware(conf, service))
	router.Use(database.Middleware(db))
	router.Use(redis.Middleware(rc))
	router.Use(middleware.RealIP)
	router.Use(middleware.Logger)
	router.Use(middleware.Timeout(timeout))
	router.Use(database.Middleware(db))
	router.Use(auth.Middleware(conf, apiconf))
	router.Use(middlewares...)

	var complexity int
	if limit, ok := conf.Get(apiconf, "max-complexity"); ok {
		complexity, err = strconv.Atoi(limit)
		if err != nil {
			panic(err)
		}
	} else {
		complexity = 250
	}

	// XXX: EmailRecover doesn't need to take config now that it's on the
	// request context
	srv := handler.GraphQL(schema,
		handler.ComplexityLimit(complexity),
		handler.RecoverFunc(EmailRecover(conf, debug, service)))

	router.Handle("/query", srv)
	router.Handle("/query/metrics", promhttp.Handler())

	if debug {
		router.Handle("/", playground.Handler("GraphQL playground", "/query"))
	}

	return router
}

// Runs the API server.
func ListenAndServe(router chi.Router) {
	log.Printf("running on %s", addr)
	log.Fatal(http.ListenAndServe(addr, router))
}