~edwargix/git.sr.ht

daa6f9b108f4e6ecb55378c000da4f696d35b68d — Drew DeVault 5 years ago 96712ac
API: refactor logic out into gql.sr.ht module
16 files changed, 84 insertions(+), 808 deletions(-)

D api/auth/auth.go
D api/crypto/crypto.go
D api/database/filter.go
D api/database/ql.go
D api/database/sq.go
M api/go.mod
M api/go.sum
M api/graph/model/acl.go
M api/graph/model/cursor.go
M api/graph/model/repository.go
M api/graph/model/user.go
D api/graph/recover.go
M api/graph/resolver.go
M api/graph/schema.resolvers.go
M api/loaders/middleware.go
M api/server.go
D api/auth/auth.go => api/auth/auth.go +0 -260
@@ 1,260 0,0 @@
package auth

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

	"github.com/vektah/gqlparser/gqlerror"

	"git.sr.ht/~sircmpwn/git.sr.ht/api/crypto"
	"git.sr.ht/~sircmpwn/git.sr.ht/api/database"
)

var userCtxKey = &contextKey{"user"}

type contextKey struct {
	name string
}

var bearerRegex = regexp.MustCompile(`^[0-9a-f]{32}$`)

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"
)

type User struct {
	ID               int
	Created          time.Time
	Updated          time.Time
	Username         string
	Email            string
	UserType         string
	URL              *string
	Location         *string
	Bio              *string
	SuspensionNotice *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)
}

type AuthCookie struct {
	Name string `json:"name"`
}

func cookieAuth(db *sql.DB, 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 (
		auth AuthCookie
		err  error
		rows *sql.Rows
		user User
	)
	if err := json.Unmarshal(payload, &auth); err != nil {
		authError(w, "Invalid authentication cookie", http.StatusForbidden)
		return
	}

	query := database.
		Select(context.TODO(), []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 = ?`, auth.Name)
	if rows, err = query.RunWith(db).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
	}
	if err := rows.Scan(&user.ID, &user.Username, &user.Created, &user.Updated,
		&user.Email, &user.UserType, &user.URL, &user.Location, &user.Bio,
		&user.SuspensionNotice); err != nil {
		panic(err)
	}
	if rows.Next() {
		if err := rows.Err(); err != nil {
			panic(err)
		}
		panic(errors.New("Multiple matching user accounts; invariant broken"))
	}

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

	ctx := context.WithValue(r.Context(), userCtxKey, &user)

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

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) {
			if r.URL.Path != "/query" {
				next.ServeHTTP(w, r)
				return
			}

			cookie, err := r.Cookie("sr.ht.unified-login.v1")
			if err == nil {
				cookieAuth(db, 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 !bearerRegex.Match(token) {
					authError(w, "Invalid bearer token, expected 32-character haxadecimal string", http.StatusBadRequest)
					return
				}
				hash := sha512.Sum512(token)
				bearer = hex.EncodeToString(hash[:])
			case "Internal":
				panic(errors.New("TODO"))
			default:
				authError(w, "Invalid Authorization header", http.StatusBadRequest)
				return
			}

			var (
				expires time.Time
				rows    *sql.Rows
				scopes  string
				user    User
			)
			query := database.
				Select(context.TODO(), []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(db).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
			}
			if err := rows.Scan(&expires, &scopes,
				&user.ID, &user.Username,
				&user.Created, &user.Updated,
				&user.Email,
				&user.UserType,
				&user.URL,
				&user.Location,
				&user.Bio,
				&user.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"))
			}

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

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

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

			ctx := context.WithValue(r.Context(), userCtxKey, &user)

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

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

D api/crypto/crypto.go => api/crypto/crypto.go +0 -60
@@ 1,60 0,0 @@
package crypto

import (
	"crypto/ed25519"
	"encoding/base64"
	"log"
	"time"

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

var (
	privateKey ed25519.PrivateKey
	publicKey  ed25519.PublicKey
	fernetKey  *fernet.Key
)

func InitCrypto(config ini.File) {
	b64key, ok := config.Get("webhooks", "private-key")
	if !ok {
		log.Fatalf("No webhook key configured")
	}
	seed, err := base64.StdEncoding.DecodeString(b64key)
	if err != nil {
		log.Fatalf("base64 decode webhooks private key: %v", err)
	}
	privateKey = ed25519.NewKeyFromSeed(seed)
	publicKey, _ = privateKey.Public().(ed25519.PublicKey)

	b64fernet, ok := config.Get("sr.ht", "network-key")
	if !ok {
		log.Fatalf("No network key configured")
	}
	fernetKey, err = fernet.DecodeKey(b64fernet)
	if err != nil {
		log.Fatalf("Load Fernet network encryption key: %v", err)
	}
}

func Sign(payload []byte) []byte {
	return ed25519.Sign(privateKey, payload)
}

func Verify(payload, signature []byte) bool {
	return ed25519.Verify(publicKey, payload, signature)
}

func Encrypt(payload []byte) []byte {
	msg, err := fernet.EncryptAndSign(payload, fernetKey)
	if err != nil {
		log.Fatalf("Error encrypting payload: %v", err)
	}
	return msg
}

func Decrypt(payload []byte) []byte {
	return fernet.VerifyAndDecrypt(payload,
		time.Duration(0), []*fernet.Key{fernetKey})
}

D api/database/filter.go => api/database/filter.go +0 -51
@@ 1,51 0,0 @@
package database

import (
	"strings"

	"github.com/google/shlex"
	sq "github.com/Masterminds/squirrel"
)

type KeyFunc func(sq.SelectBuilder, string) (string, error)

type SearchTerm struct {
	Key     string
	Value   string
	Inverse bool
}

type Searchable interface {
	Selectable

	// Update the select builder for bare search terms
	DefaultSearch(sq.SelectBuilder, string) (sq.SelectBuilder, error)

	// Return a map of KeyFuncs for each search key, whose values update the
	// select builder for the given search term
	//KeySearch() map[string]KeyFunc

	// Update the select builder for a key/value pair which is unknown
	//FallbackSearch(sq.SelectBuilder,
	//	key, value string) (sq.SelectBuilder, error)
}

func ApplyFilter(query sq.SelectBuilder, resource Searchable,
	search string) (sq.SelectBuilder, error) {
	terms, err := shlex.Split(search)
	if err != nil {
		return query, err
	}

	for _, term := range terms {
		parts := strings.SplitN(term, ":", 2)
		if len(parts) == 1 {
			query, err = resource.DefaultSearch(query, term)
			if err != nil {
				return query, err
			}
		}
	}

	return query, nil
}

D api/database/ql.go => api/database/ql.go +0 -97
@@ 1,97 0,0 @@
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 ColumnsFor(ctx context.Context, alias string,
	colMap map[string]string) []string {

	fields := collectFields(ctx)
	if len(fields) == 0 {
		// Collect all fields if we are not in an active graphql context
		for qlCol, _ := range colMap {
			fields = append(fields, graphql.CollectedField{
				&ast.Field{Name: qlCol}, nil,
			})
		}
	}

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

	var columns []string
	for _, qlCol := range fields {
		if sqlCol, ok := colMap[qlCol.Name]; ok {
			if alias != "" {
				columns = append(columns, pq.QuoteIdentifier(alias)+
					"."+pq.QuoteIdentifier(sqlCol))
			} else {
				columns = append(columns, pq.QuoteIdentifier(sqlCol))
			}
		}
	}

	return columns
}

func FieldsFor(ctx context.Context,
	colMap map[string]interface{}) []interface{} {

	qlFields := collectFields(ctx)
	if len(qlFields) == 0 {
		// Collect all fields if we are not in an active graphql context
		for qlCol, _ := range colMap {
			qlFields = append(qlFields, graphql.CollectedField{
				&ast.Field{Name: qlCol}, 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 := colMap[qlField.Name]; ok {
			fields = append(fields, field)
		}
	}

	return fields
}

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

D api/database/sq.go => api/database/sq.go +0 -30
@@ 1,30 0,0 @@
package database

import (
	"context"
	"fmt"

	sq "github.com/Masterminds/squirrel"
)

type Selectable interface {
	Select(ctx context.Context) []string
	Fields(ctx context.Context) []interface{}
}

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 Selectable:
			q = q.Columns(col.Select(ctx)...)
		default:
			panic(fmt.Errorf("Unknown selectable type %T", col))
		}
	}
	return q
}

M api/go.mod => api/go.mod +8 -4
@@ 4,28 4,32 @@ go 1.14

require (
	git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3
	git.sr.ht/~sircmpwn/gql.sr.ht v0.0.0-20200519155752-5492494ccefa
	github.com/99designs/gqlgen v0.11.4-0.20200512031635-40570d1b4d70
	github.com/Masterminds/squirrel v1.2.0
	github.com/Masterminds/squirrel v1.4.0
	github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
	github.com/fernet/fernet-go v0.0.0-20191111064656-eff2850e6001
	github.com/go-chi/chi v3.3.2+incompatible
	github.com/go-chi/chi v4.1.1+incompatible
	github.com/go-git/go-git/v5 v5.0.0
	github.com/golang/protobuf v1.4.2 // indirect
	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
	github.com/gorilla/websocket v1.4.2 // indirect
	github.com/hashicorp/golang-lru v0.5.4 // indirect
	github.com/lib/pq v1.3.0
	github.com/lib/pq v1.5.2
	github.com/martinlindhe/base36 v1.0.0
	github.com/matryer/moq v0.0.0-20200310130814-7721994d1b54 // indirect
	github.com/mitchellh/mapstructure v1.3.0 // indirect
	github.com/pkg/errors v0.9.1 // indirect
	github.com/prometheus/client_golang v1.6.0 // indirect
	github.com/prometheus/common v0.10.0 // indirect
	github.com/urfave/cli/v2 v2.2.0 // indirect
	github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec
	github.com/vektah/dataloaden v0.3.0 // indirect
	github.com/vektah/gqlparser v1.3.1
	github.com/vektah/gqlparser/v2 v2.0.1
	golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073
	golang.org/x/tools v0.0.0-20200513201620-d5fe73897c97 // indirect
	golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 // indirect
	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
	gopkg.in/mail.v2 v2.3.1
	gopkg.in/yaml.v2 v2.3.0 // indirect
)

M api/go.sum => api/go.sum +24 -0
@@ 4,6 4,12 @@ git.sr.ht/~sircmpwn/git.sr.ht v0.0.0-20200405134845-b8fbf5bf484f h1:SW8+xV65kcga
git.sr.ht/~sircmpwn/git.sr.ht v0.0.0-20200413150414-046cd382d7b7 h1:PYRTIcsHR5W+aPn98OCC73ly528uw5o/4Z3b5Rvc7vA=
git.sr.ht/~sircmpwn/git.sr.ht v0.0.0-20200430231646-4014870a700b h1:xHyh0xixoMog7F1kqOG55daFfK0uso98i0lQ/D4dSM8=
git.sr.ht/~sircmpwn/git.sr.ht v0.0.0-20200511133609-1161d017dc00 h1:uHwXamWDLQX54bOBJcGub96xOX6ApQtQWJ83QmE39OM=
git.sr.ht/~sircmpwn/gql.sr.ht v0.0.0-20200519142817-97b51d504448 h1:fnNlKiQ/bV8EHlHwWwUQ/icu+ec0MtEgSuwleCUIM40=
git.sr.ht/~sircmpwn/gql.sr.ht v0.0.0-20200519142817-97b51d504448/go.mod h1:V38DHc2+k1GmbNT4pE6OH/2fOXYcZHn9o/PUAEXH1P0=
git.sr.ht/~sircmpwn/gql.sr.ht v0.0.0-20200519153250-9929f08a73ba h1:Sar7LLCjGDyP1J/MmnNyPKIlpnZBV9f5ULLi5aSck8M=
git.sr.ht/~sircmpwn/gql.sr.ht v0.0.0-20200519153250-9929f08a73ba/go.mod h1:V38DHc2+k1GmbNT4pE6OH/2fOXYcZHn9o/PUAEXH1P0=
git.sr.ht/~sircmpwn/gql.sr.ht v0.0.0-20200519155752-5492494ccefa h1:yh3hxZcollhjHZHEjkbh9PrBBFFNClYh0b5SAk+zJAY=
git.sr.ht/~sircmpwn/gql.sr.ht v0.0.0-20200519155752-5492494ccefa/go.mod h1:V38DHc2+k1GmbNT4pE6OH/2fOXYcZHn9o/PUAEXH1P0=
git.sr.ht/~sircmpwn/gqlgen v0.0.0-20200412134447-57d7234737d4 h1:J/Sb88htNHzZaN6ZEF8BnRWj3LzYoTrOL4WRhZEEiQE=
git.sr.ht/~sircmpwn/gqlgen v0.0.0-20200412134447-57d7234737d4/go.mod h1:W1cijL2EqAyL1eo1WAJ3ijNVkZM2okpYyCF5TRu1VfI=
github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4=


@@ 13,6 19,8 @@ github.com/99designs/gqlgen v0.11.4-0.20200512031635-40570d1b4d70/go.mod h1:RgX5
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
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=


@@ 47,6 55,8 @@ github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-chi/chi v3.3.2+incompatible h1:uQNcQN3NsV1j4ANsPh42P4ew4t6rnRbJb8frvpp31qQ=
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.1.1+incompatible h1:MmTgB0R8Bt/jccxp+t6S/1VGIKdJw5J74CK/c9tTfA4=
github.com/go-chi/chi v4.1.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM=


@@ 72,6 82,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0 h1:oOuy+ugB+P/kBdUnG5QaMXSIyJ1q38wWSojYCb3z5VQ=
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=


@@ 108,6 120,8 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.5.2 h1:yTSXVswvWUOQ3k1sd7vJfDrbSl8lKuscqFJRqjC0ifw=
github.com/lib/pq v1.5.2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/martinlindhe/base36 v1.0.0 h1:eYsumTah144C0A8P1T/AVSUk5ZoLnhfYFM3OGQxB52A=
github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=


@@ 153,6 167,8 @@ github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6T
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1 h1:KOMtN28tlbam3/7ZKEYKHhKoJZYYj3gMH4uc62x7X7U=
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 h1:DhHlBtkHWPYi8O2y31JkK0TF+DGM+51OopZjH/Ia5qI=


@@ 201,6 217,7 @@ golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ=
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-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=


@@ 226,6 243,8 @@ golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f h1:gWF768j/LaZugp8dyS4UwsslYCYz9XgFxvlgsn0n9H8=
golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/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=


@@ 238,6 257,7 @@ golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef h1:RHORRhs540cYZYrzgU2CPUy
golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200513201620-d5fe73897c97 h1:DAuln/hGp+aJiHpID1Y1hYzMEPP5WLwtZHPb50mN0OE=
golang.org/x/tools v0.0.0-20200513201620-d5fe73897c97/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
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 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=


@@ 248,7 268,11 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
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 h1:qdOKuR/EIArgaWNjetjgTzgVTAZ+S/WXVrq9HW9zimw=
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/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
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/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

M api/graph/model/acl.go => api/graph/model/acl.go +1 -1
@@ 8,7 8,7 @@ import (

	sq "github.com/Masterminds/squirrel"

	"git.sr.ht/~sircmpwn/git.sr.ht/api/database"
	"git.sr.ht/~sircmpwn/gql.sr.ht/database"
)

// TODO: Drop updated column from database

M api/graph/model/cursor.go => api/graph/model/cursor.go +1 -1
@@ 5,7 5,7 @@ import (
	"fmt"
	"io"

	"git.sr.ht/~sircmpwn/git.sr.ht/api/crypto"
	"git.sr.ht/~sircmpwn/gql.sr.ht/crypto"
)

// TODO: Add a field for the resource this is intended to be used with

M api/graph/model/repository.go => api/graph/model/repository.go +1 -1
@@ 9,7 9,7 @@ import (
	"github.com/go-git/go-git/v5"
	sq "github.com/Masterminds/squirrel"

	"git.sr.ht/~sircmpwn/git.sr.ht/api/database"
	"git.sr.ht/~sircmpwn/gql.sr.ht/database"
)

type Repository struct {

M api/graph/model/user.go => api/graph/model/user.go +1 -1
@@ 4,7 4,7 @@ import (
	"context"
	"time"

	"git.sr.ht/~sircmpwn/git.sr.ht/api/database"
	"git.sr.ht/~sircmpwn/gql.sr.ht/database"
)

type User struct {

D api/graph/recover.go => api/graph/recover.go +0 -127
@@ 1,127 0,0 @@
package graph

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/git.sr.ht/api/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) 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(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(
			"[git.sr.ht] GraphQL query error: %v", 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)
}

M api/graph/resolver.go => api/graph/resolver.go +1 -7
@@ 2,10 2,4 @@ package graph

//go:generate go run github.com/99designs/gqlgen

import (
	"database/sql"
)

type Resolver struct {
	DB *sql.DB
}
type Resolver struct {}

M api/graph/schema.resolvers.go => api/graph/schema.resolvers.go +7 -7
@@ 9,8 9,8 @@ import (
	"sort"
	"strings"

	"git.sr.ht/~sircmpwn/git.sr.ht/api/auth"
	"git.sr.ht/~sircmpwn/git.sr.ht/api/database"
	"git.sr.ht/~sircmpwn/gql.sr.ht/auth"
	"git.sr.ht/~sircmpwn/gql.sr.ht/database"
	"git.sr.ht/~sircmpwn/git.sr.ht/api/graph/api"
	"git.sr.ht/~sircmpwn/git.sr.ht/api/graph/model"
	"git.sr.ht/~sircmpwn/git.sr.ht/api/loaders"


@@ 31,7 31,7 @@ func (r *aCLResolver) Repository(ctx context.Context, obj *model.ACL) (*model.Re
		From(`repository repo`).
		Join(`access acl ON acl.repo_id = repo.id`).
		Where(`acl.id = ?`, obj.ID)
	row := query.RunWith(r.DB).QueryRow()
	row := query.RunWith(database.ForContext(ctx)).QueryRow()
	if err := row.Scan(repo.Fields(ctx)...); err != nil {
		panic(err)
	}


@@ 48,7 48,7 @@ func (r *aCLResolver) Entity(ctx context.Context, obj *model.ACL) (model.Entity,
		From(`"user" u`).
		Join(`access acl ON acl.user_id = u.id`).
		Where(`acl.id = ?`, obj.ID)
	row := query.RunWith(r.DB).QueryRow()
	row := query.RunWith(database.ForContext(ctx)).QueryRow()
	if err := row.Scan(user.Fields(ctx)...); err != nil {
		panic(err)
	}


@@ 125,7 125,7 @@ func (r *queryResolver) Repositories(ctx context.Context, cursor *model.Cursor, 
		From(`repository repo`).
		Where(`repo.owner_id = ?`, auth.ForContext(ctx).ID)

	repos, cursor := repo.QueryWithCursor(ctx, r.DB, query, cursor)
	repos, cursor := repo.QueryWithCursor(ctx, database.ForContext(ctx), query, cursor)
	return &model.RepositoryCursor{repos, cursor}, nil
}



@@ 164,7 164,7 @@ func (r *repositoryResolver) AccessControlList(ctx context.Context, obj *model.R
		Where(`acl.repo_id = ?`, obj.ID).
		Where(`repo.owner_id = ?`, auth.ForContext(ctx).ID)

	acls, cursor := acl.QueryWithCursor(ctx, r.DB, query, cursor)
	acls, cursor := acl.QueryWithCursor(ctx, database.ForContext(ctx), query, cursor)
	return &model.ACLCursor{acls, cursor}, nil
}



@@ 367,7 367,7 @@ func (r *userResolver) Repositories(ctx context.Context, obj *model.User, cursor
		From(`repository repo`).
		Where(`repo.owner_id = ?`, obj.ID)

	repos, cursor := repo.QueryWithCursor(ctx, r.DB, query, cursor)
	repos, cursor := repo.QueryWithCursor(ctx, database.ForContext(ctx), query, cursor)
	return &model.RepositoryCursor{repos, cursor}, nil
}


M api/loaders/middleware.go => api/loaders/middleware.go +34 -35
@@ 16,8 16,8 @@ import (
	sq "github.com/Masterminds/squirrel"
	"github.com/lib/pq"

	"git.sr.ht/~sircmpwn/git.sr.ht/api/auth"
	"git.sr.ht/~sircmpwn/git.sr.ht/api/database"
	"git.sr.ht/~sircmpwn/gql.sr.ht/auth"
	"git.sr.ht/~sircmpwn/gql.sr.ht/database"
	"git.sr.ht/~sircmpwn/git.sr.ht/api/graph/model"
)



@@ 258,40 258,39 @@ func fetchRepositoriesByOwnerRepoName(ctx context.Context,
	}
}

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(), loadersCtxKey, &Loaders{
				UsersByID: UsersByIDLoader{
					maxBatch: 100,
					wait:     1 * time.Millisecond,
					fetch:    fetchUsersByID(r.Context(), db),
				},
				UsersByName: UsersByNameLoader{
					maxBatch: 100,
					wait:     1 * time.Millisecond,
					fetch:    fetchUsersByName(r.Context(), db),
				},
				RepositoriesByID: RepositoriesByIDLoader{
					maxBatch: 100,
					wait:     1 * time.Millisecond,
					fetch:    fetchRepositoriesByID(r.Context(), db),
				},
				RepositoriesByName: RepositoriesByNameLoader{
					maxBatch: 100,
					wait:     1 * time.Millisecond,
					fetch:    fetchRepositoriesByName(r.Context(), db),
				},
				RepositoriesByOwnerRepoName: RepositoriesByOwnerRepoNameLoader{
					maxBatch: 100,
					wait:     1 * time.Millisecond,
					fetch:    fetchRepositoriesByOwnerRepoName(r.Context(), db),
				},
			})
			r = r.WithContext(ctx)
			next.ServeHTTP(w, r)
func Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		db := database.ForContext(r.Context())
		ctx := context.WithValue(r.Context(), loadersCtxKey, &Loaders{
			UsersByID: UsersByIDLoader{
				maxBatch: 100,
				wait:     1 * time.Millisecond,
				fetch:    fetchUsersByID(r.Context(), db),
			},
			UsersByName: UsersByNameLoader{
				maxBatch: 100,
				wait:     1 * time.Millisecond,
				fetch:    fetchUsersByName(r.Context(), db),
			},
			RepositoriesByID: RepositoriesByIDLoader{
				maxBatch: 100,
				wait:     1 * time.Millisecond,
				fetch:    fetchRepositoriesByID(r.Context(), db),
			},
			RepositoriesByName: RepositoriesByNameLoader{
				maxBatch: 100,
				wait:     1 * time.Millisecond,
				fetch:    fetchRepositoriesByName(r.Context(), db),
			},
			RepositoriesByOwnerRepoName: RepositoriesByOwnerRepoNameLoader{
				maxBatch: 100,
				wait:     1 * time.Millisecond,
				fetch:    fetchRepositoriesByOwnerRepoName(r.Context(), db),
			},
		})
	}
		r = r.WithContext(ctx)
		next.ServeHTTP(w, r)
	})
}

func ForContext(ctx context.Context) *Loaders {

M api/server.go => api/server.go +6 -126
@@ 1,140 1,20 @@
package main

import (
	"database/sql"
	"log"
	"net/http"
	"os"
	"strconv"
	"time"
	"git.sr.ht/~sircmpwn/gql.sr.ht"

	"git.sr.ht/~sircmpwn/getopt"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/99designs/gqlgen/handler"
	"github.com/go-chi/chi"
	"github.com/go-chi/chi/middleware"
	"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"
	_ "github.com/lib/pq"

	"git.sr.ht/~sircmpwn/git.sr.ht/api/auth"
	"git.sr.ht/~sircmpwn/git.sr.ht/api/crypto"
	"git.sr.ht/~sircmpwn/git.sr.ht/api/graph"
	"git.sr.ht/~sircmpwn/git.sr.ht/api/graph/api"
	"git.sr.ht/~sircmpwn/git.sr.ht/api/loaders"
)

const defaultAddr = ":5101"

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

func main() {
	var (
		addr   string = defaultAddr
		config ini.File
		debug  bool
		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)

	pgcs, ok := config.Get("git.sr.ht", "connection-string")
	if !ok {
		log.Fatalf("No connection string configured for git.sr.ht: %v", err)
	}
	appConfig := gql.LoadConfig(":5101")

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

	var timeout time.Duration
	if to, ok := config.Get("git.sr.ht::api", "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(auth.Middleware(db))
	router.Use(loaders.Middleware(db))
	router.Use(middleware.Logger)
	router.Use(middleware.Timeout(timeout))

	gqlConfig := api.Config{
		Resolvers: &graph.Resolver{DB: db},
	}
	gqlConfig := api.Config{Resolvers: &graph.Resolver{}}
	graph.ApplyComplexity(&gqlConfig)
	schema := api.NewExecutableSchema(gqlConfig)

	var complexity int
	if limit, ok := config.Get("git.sr.ht::api", "max-complexity"); ok {
		complexity, err = strconv.Atoi(limit)
		if err != nil {
			panic(err)
		}
	} else {
		complexity = 100
	}

	srv := handler.GraphQL(
		api.NewExecutableSchema(gqlConfig),
		handler.ComplexityLimit(complexity),
		handler.RecoverFunc(graph.EmailRecover(config, debug)))

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

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

	log.Printf("running on %s", addr)
	log.Fatal(http.ListenAndServe(addr, router))
	router := gql.MakeRouter("git.sr.ht", appConfig, schema, loaders.Middleware)
	gql.ListenAndServe(router)
}