From 1d2a30cb44dc685860d9efe764e0ac2d50ab4c5d Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Tue, 6 Oct 2020 11:48:01 -0400 Subject: [PATCH] Import GQL server interfaces from gql.sr.ht --- auth/middleware.go | 736 +++++++++++++++++++++++++++++++++++++++++ auth/token.go | 77 +++++ config/middleware.go | 44 +++ database/middleware.go | 53 +++ database/ql.go | 96 ++++++ database/sq.go | 142 ++++++++ go.mod | 13 + go.sum | 227 +++++++++++++ server/directives.go | 51 +++ server/email.go | 128 +++++++ server/server.go | 173 ++++++++++ 11 files changed, 1740 insertions(+) create mode 100644 auth/middleware.go create mode 100644 auth/token.go create mode 100644 config/middleware.go create mode 100644 database/middleware.go create mode 100644 database/ql.go create mode 100644 database/sq.go create mode 100644 server/directives.go create mode 100644 server/email.go create mode 100644 server/server.go diff --git a/auth/middleware.go b/auth/middleware.go new file mode 100644 index 0000000000000000000000000000000000000000..7b147be5cf96732ba85fb04255959174473531d8 --- /dev/null +++ b/auth/middleware.go @@ -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 '`, 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 +} diff --git a/auth/token.go b/auth/token.go new file mode 100644 index 0000000000000000000000000000000000000000..1e21e878bb85a129ff30017ecb34adb7343671f7 --- /dev/null +++ b/auth/token.go @@ -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 +} diff --git a/config/middleware.go b/config/middleware.go new file mode 100644 index 0000000000000000000000000000000000000000..fd238db3cf4c3503adfbcc3ec623e04efd1e5685 --- /dev/null +++ b/config/middleware.go @@ -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 +} diff --git a/database/middleware.go b/database/middleware.go new file mode 100644 index 0000000000000000000000000000000000000000..9e2761e48225b37b3962d61a03ea174ebd9f65c3 --- /dev/null +++ b/database/middleware.go @@ -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) +} diff --git a/database/ql.go b/database/ql.go new file mode 100644 index 0000000000000000000000000000000000000000..bb8fddad1f14ff95cc7c182259530929c4888f45 --- /dev/null +++ b/database/ql.go @@ -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) + } +} diff --git a/database/sq.go b/database/sq.go new file mode 100644 index 0000000000000000000000000000000000000000..58b558e0f3b4b9d7ca9b7f07489c42cd2ccb0780 --- /dev/null +++ b/database/sq.go @@ -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 +} diff --git a/go.mod b/go.mod index 57f8c8ba5095a04eb6e86a83a37702570cb5f56a..2b36d8db0dce7f03838b305a1a978f93e07f693f 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 55ceb904c4b35a438570460875e860044061c395..1aadf6f51d411b1420128f9f6c3dfea8c2df19f2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/server/directives.go b/server/directives.go new file mode 100644 index 0000000000000000000000000000000000000000..9afef56489321c873d97f140a179ea41d11451c8 --- /dev/null +++ b/server/directives.go @@ -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") +} diff --git a/server/email.go b/server/email.go new file mode 100644 index 0000000000000000000000000000000000000000..9c233240eea546b104804e949596418a970dda15 --- /dev/null +++ b/server/email.go @@ -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) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000000000000000000000000000000000000..d41b74ab2199c99b0dbe8ae0faaf3749383792f2 --- /dev/null +++ b/server/server.go @@ -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)) +}