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))
+}