From 1611a3c80cd8b3b0ba4ed6f3019ed897bc1d392c Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Wed, 22 Apr 2020 17:26:56 -0400 Subject: [PATCH] Initial work on a new cursor design --- api/crypto/crypto.go | 60 +++++ api/go.mod | 2 + api/go.sum | 2 + api/gqlgen.yml | 3 + api/graph/generated/generated.go | 390 ++++++++++++++++++++----------- api/graph/model/cursor.go | 41 ++++ api/graph/model/models_gen.go | 6 +- api/graph/model/repository.go | 1 + api/graph/schema.graphqls | 39 +++- api/graph/schema.resolvers.go | 70 +++--- api/server.go | 3 + 11 files changed, 421 insertions(+), 196 deletions(-) create mode 100644 api/crypto/crypto.go create mode 100644 api/graph/model/cursor.go diff --git a/api/crypto/crypto.go b/api/crypto/crypto.go new file mode 100644 index 0000000..553fb75 --- /dev/null +++ b/api/crypto/crypto.go @@ -0,0 +1,60 @@ +package crypto + +import ( + "crypto/ed25519" + "encoding/base64" + "log" + "time" + + "github.com/fernet/fernet-go" + "github.com/vaughan0/go-ini" +) + +var ( + privateKey ed25519.PrivateKey + publicKey ed25519.PublicKey + fernetKey *fernet.Key +) + +func InitCrypto(config ini.File) { + b64key, ok := config.Get("webhooks", "private-key") + if !ok { + log.Fatalf("No webhook key configured") + } + seed, err := base64.StdEncoding.DecodeString(b64key) + if err != nil { + log.Fatalf("base64 decode webhooks private key: %v", err) + } + privateKey = ed25519.NewKeyFromSeed(seed) + publicKey, _ = privateKey.Public().(ed25519.PublicKey) + + b64fernet, ok := config.Get("sr.ht", "network-key") + if !ok { + log.Fatalf("No network key configured") + } + fernetKey, err = fernet.DecodeKey(b64fernet) + if err != nil { + log.Fatalf("Load Fernet network encryption key: %v", err) + } +} + +func Sign(payload []byte) []byte { + return ed25519.Sign(privateKey, payload) +} + +func Verify(payload, signature []byte) bool { + return ed25519.Verify(publicKey, payload, signature) +} + +func Encrypt(payload []byte) []byte { + msg, err := fernet.EncryptAndSign(payload, fernetKey) + if err != nil { + log.Fatalf("Error encrypting payload: %v", err) + } + return msg +} + +func Decrypt(payload []byte) []byte { + return fernet.VerifyAndDecrypt(payload, + time.Duration(0), []*fernet.Key{fernetKey}) +} diff --git a/api/go.mod b/api/go.mod index 41eed32..0ee78c6 100644 --- a/api/go.mod +++ b/api/go.mod @@ -7,6 +7,7 @@ require ( git.sr.ht/~sircmpwn/gqlgen v0.0.0-20200412134447-57d7234737d4 github.com/Masterminds/squirrel v1.2.0 github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect + github.com/fernet/fernet-go v0.0.0-20191111064656-eff2850e6001 github.com/go-chi/chi v3.3.2+incompatible github.com/go-git/go-git/v5 v5.0.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 @@ -21,6 +22,7 @@ require ( github.com/vektah/dataloaden v0.3.0 // indirect github.com/vektah/gqlparser v1.3.1 github.com/vektah/gqlparser/v2 v2.0.1 + golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef // indirect gopkg.in/yaml.v2 v2.2.8 // indirect ) diff --git a/api/go.sum b/api/go.sum index 1ea91e5..d770192 100644 --- a/api/go.sum +++ b/api/go.sum @@ -25,6 +25,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +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/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-chi/chi v3.3.2+incompatible h1:uQNcQN3NsV1j4ANsPh42P4ew4t6rnRbJb8frvpp31qQ= diff --git a/api/gqlgen.yml b/api/gqlgen.yml index 9d0b94c..ebefa20 100644 --- a/api/gqlgen.yml +++ b/api/gqlgen.yml @@ -54,3 +54,6 @@ models: - git.sr.ht/~sircmpwn/gqlgen/graphql.Int - git.sr.ht/~sircmpwn/gqlgen/graphql.Int64 - git.sr.ht/~sircmpwn/gqlgen/graphql.Int32 + Cursor: + model: + - git.sr.ht/~sircmpwn/git.sr.ht/api/graph/model.Cursor diff --git a/api/graph/generated/generated.go b/api/graph/generated/generated.go index 2da84ab..a610385 100644 --- a/api/graph/generated/generated.go +++ b/api/graph/generated/generated.go @@ -40,6 +40,7 @@ type ResolverRoot interface { Mutation() MutationResolver Query() QueryResolver Repository() RepositoryResolver + Tree() TreeResolver User() UserResolver } @@ -101,8 +102,9 @@ type ComplexityRoot struct { } Query struct { + Cursor func(childComplexity int, filter model.Filter) int Me func(childComplexity int) int - Repositories func(childComplexity int, count *int, next *int, filter *model.FilterBy) int + Repositories func(childComplexity int, cursor *model.Cursor) int Repository func(childComplexity int, id int) int RepositoryByName func(childComplexity int, name string) int RepositoryByOwner func(childComplexity int, owner string, repo string) int @@ -117,17 +119,18 @@ type ComplexityRoot struct { } Repository struct { - AccessControlList func(childComplexity int, count *int, next *int) int + AccessControlList func(childComplexity int, cursor *model.Cursor) int Created func(childComplexity int) int + Cursor func(childComplexity int) int Description func(childComplexity int) int File func(childComplexity int, revspec *string, path string) int Head func(childComplexity int) int ID func(childComplexity int) int - Log func(childComplexity int, revspec *string, count *int) int + Log func(childComplexity int, cursor *model.Cursor) int Name func(childComplexity int) int Objects func(childComplexity int, ids []*string) int Owner func(childComplexity int) int - References func(childComplexity int, count *int, next *string, glob *string) int + References func(childComplexity int, cursor *model.Cursor) int RevparseSingle func(childComplexity int, revspec string) int Tree func(childComplexity int, revspec *string, path *string) int Updated func(childComplexity int) int @@ -157,7 +160,7 @@ type ComplexityRoot struct { } Tree struct { - Entries func(childComplexity int, count *int, next *string) int + Entries func(childComplexity int, cursor *model.Cursor) int Entry func(childComplexity int, path string) int ID func(childComplexity int) int Raw func(childComplexity int) int @@ -179,7 +182,7 @@ type ComplexityRoot struct { Email func(childComplexity int) int ID func(childComplexity int) int Location func(childComplexity int) int - Repositories func(childComplexity int, count *int, next *int, filter *model.FilterBy) int + Repositories func(childComplexity int, cursor *model.Cursor) int URL func(childComplexity int) int Updated func(childComplexity int) int Username func(childComplexity int) int @@ -205,8 +208,9 @@ type MutationResolver interface { type QueryResolver interface { Version(ctx context.Context) (*model.Version, error) Me(ctx context.Context) (*model.User, error) + Cursor(ctx context.Context, filter model.Filter) (*model.Cursor, error) User(ctx context.Context, username string) (*model.User, error) - Repositories(ctx context.Context, count *int, next *int, filter *model.FilterBy) ([]*model.Repository, error) + Repositories(ctx context.Context, cursor *model.Cursor) ([]*model.Repository, error) Repository(ctx context.Context, id int) (*model.Repository, error) RepositoryByName(ctx context.Context, name string) (*model.Repository, error) RepositoryByOwner(ctx context.Context, owner string, repo string) (*model.Repository, error) @@ -214,11 +218,14 @@ type QueryResolver interface { type RepositoryResolver interface { Owner(ctx context.Context, obj *model.Repository) (model.Entity, error) - AccessControlList(ctx context.Context, obj *model.Repository, count *int, next *int) ([]*model.ACL, error) - References(ctx context.Context, obj *model.Repository, count *int, next *string, glob *string) ([]*model.Reference, error) + AccessControlList(ctx context.Context, obj *model.Repository, cursor *model.Cursor) ([]*model.ACL, error) + References(ctx context.Context, obj *model.Repository, cursor *model.Cursor) ([]*model.Reference, error) +} +type TreeResolver interface { + Entries(ctx context.Context, obj *model.Tree, cursor *model.Cursor) ([]*model.TreeEntry, error) } type UserResolver interface { - Repositories(ctx context.Context, obj *model.User, count *int, next *int, filter *model.FilterBy) ([]*model.Repository, error) + Repositories(ctx context.Context, obj *model.User, cursor *model.Cursor) ([]*model.Repository, error) } type executableSchema struct { @@ -516,6 +523,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.UploadArtifact(childComplexity, args["repoId"].(int), args["revspec"].(string), args["file"].(graphql.Upload)), true + case "Query.cursor": + if e.complexity.Query.Cursor == nil { + break + } + + args, err := ec.field_Query_cursor_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.Cursor(childComplexity, args["filter"].(model.Filter)), true + case "Query.me": if e.complexity.Query.Me == nil { break @@ -533,7 +552,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Query.Repositories(childComplexity, args["count"].(*int), args["next"].(*int), args["filter"].(*model.FilterBy)), true + return e.complexity.Query.Repositories(childComplexity, args["cursor"].(*model.Cursor)), true case "Query.repository": if e.complexity.Query.Repository == nil { @@ -621,7 +640,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Repository.AccessControlList(childComplexity, args["count"].(*int), args["next"].(*int)), true + return e.complexity.Repository.AccessControlList(childComplexity, args["cursor"].(*model.Cursor)), true case "Repository.created": if e.complexity.Repository.Created == nil { @@ -630,6 +649,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Repository.Created(childComplexity), true + case "Repository.cursor": + if e.complexity.Repository.Cursor == nil { + break + } + + return e.complexity.Repository.Cursor(childComplexity), true + case "Repository.description": if e.complexity.Repository.Description == nil { break @@ -673,7 +699,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Repository.Log(childComplexity, args["revspec"].(*string), args["count"].(*int)), true + return e.complexity.Repository.Log(childComplexity, args["cursor"].(*model.Cursor)), true case "Repository.name": if e.complexity.Repository.Name == nil { @@ -711,7 +737,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Repository.References(childComplexity, args["count"].(*int), args["next"].(*string), args["glob"].(*string)), true + return e.complexity.Repository.References(childComplexity, args["cursor"].(*model.Cursor)), true case "Repository.revparse_single": if e.complexity.Repository.RevparseSingle == nil { @@ -852,7 +878,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Tree.Entries(childComplexity, args["count"].(*int), args["next"].(*string)), true + return e.complexity.Tree.Entries(childComplexity, args["cursor"].(*model.Cursor)), true case "Tree.entry": if e.complexity.Tree.Entry == nil { @@ -974,7 +1000,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.User.Repositories(childComplexity, args["count"].(*int), args["next"].(*int), args["filter"].(*model.FilterBy)), true + return e.complexity.User.Repositories(childComplexity, args["cursor"].(*model.Cursor)), true case "User.url": if e.complexity.User.URL == nil { @@ -1089,7 +1115,8 @@ func (ec *executionContext) introspectType(name string) (*introspection.Type, er } var sources = []*ast.Source{ - &ast.Source{Name: "graph/schema.graphqls", Input: `scalar Time + &ast.Source{Name: "graph/schema.graphqls", Input: `scalar Cursor +scalar Time scalar Upload # Representation of a semantic API version @@ -1131,7 +1158,7 @@ interface Entity { canonicalName: String! # A list of repositories owned by this entity - repositories(count: Int = 10, next: Int, filter: FilterBy): [Repository]! + repositories(cursor: Cursor): [Repository]! } # A registered user @@ -1147,7 +1174,7 @@ type User implements Entity { bio: String # A list of repositories owned by this user - repositories(count: Int = 10, next: Int, filter: FilterBy): [Repository]! + repositories(cursor: Cursor): [Repository]! } # A git repository @@ -1160,12 +1187,15 @@ type Repository { description: String visibility: Visibility! + # Pass this into the same endpoint to receive a new page of results + cursor: Cursor + # If this repository was cloned from another, this will be set to the # original clone URL upstreamUrl: String # Returns access control list entries for this repository - accessControlList(count: Int = 10, next: Int): [ACL]! + accessControlList(cursor: Cursor): [ACL]! ## Plumbing API: @@ -1173,7 +1203,7 @@ type Repository { # # glob: an optional string to filter the list of references, e.g. for tags # use "refs/tags/*", or leave null to enumerate all references - references(count: Int = 10, next: String, glob: String): [Reference]! + references(cursor: Cursor): [Reference]! # Returns a list of objects for this repository by their IDs (using fully # qualified git object IDs, 40 character hex strings) @@ -1188,8 +1218,9 @@ type Repository { # The HEAD reference for this repository (equivalent to the default branch) HEAD: Reference - # Returns a list of comments, starting from revspec - log(revspec: String = "HEAD", count: Int = 10): [Commit]! + # Returns a list of comments in topological order. ` + "`" + `cursor.from` + "`" + ` is used as + # the revspec to begin logging from. + log(cursor: Cursor): [Commit]! # Returns the tree for a given revspec # @@ -1269,7 +1300,7 @@ type Tree implements Object { shortId: String! raw: String! # TODO: add globbing - entries(count: Int = 100, next: String): [TreeEntry!]! + entries(cursor: Cursor): [TreeEntry!]! entry(path: String): TreeEntry } @@ -1319,10 +1350,16 @@ type Tag implements Object { message: String } -# Specifies filtering criteria for a listing query -input FilterBy { - # Same search syntax as searching on the web UI - search: String! +input Filter { + # Number of results to return. + count: Int = 20 + + # Search terms. The exact meaning varies by usage, but generally these are + # compatible with the web UI's search syntax. + search: String + + # Field to order results by, if possible. + orderBy: String } type Query { @@ -1332,16 +1369,20 @@ type Query { # Returns the authenticated user me: User! + # Returns a custom cursor based on a given filter criteria + cursor(filter: Filter!): Cursor + # Returns a specific user user(username: String!): User # Returns repositories that the authenticated user has access to + # # NOTE: in this version of the API, only repositories owned by the # authenticated user are returned, but in the future the default behavior # will be to return all repositories that the user either (1) has been given # explicit access to via ACLs or (2) has implicit access to either by # ownership or group membership. - repositories(count: Int = 10, next: Int, filter: FilterBy): [Repository]! + repositories(cursor: Cursor): [Repository]! # Returns a specific repository repository(id: Int!): Repository @@ -1551,33 +1592,31 @@ func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs return args, nil } -func (ec *executionContext) field_Query_repositories_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { +func (ec *executionContext) field_Query_cursor_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} - var arg0 *int - if tmp, ok := rawArgs["count"]; ok { - arg0, err = ec.unmarshalOInt2ᚖint(ctx, tmp) - if err != nil { - return nil, err - } - } - args["count"] = arg0 - var arg1 *int - if tmp, ok := rawArgs["next"]; ok { - arg1, err = ec.unmarshalOInt2ᚖint(ctx, tmp) + var arg0 model.Filter + if tmp, ok := rawArgs["filter"]; ok { + arg0, err = ec.unmarshalNFilter2gitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐFilter(ctx, tmp) if err != nil { return nil, err } } - args["next"] = arg1 - var arg2 *model.FilterBy - if tmp, ok := rawArgs["filter"]; ok { - arg2, err = ec.unmarshalOFilterBy2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐFilterBy(ctx, tmp) + args["filter"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Query_repositories_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 *model.Cursor + if tmp, ok := rawArgs["cursor"]; ok { + arg0, err = ec.unmarshalOCursor2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx, tmp) if err != nil { return nil, err } } - args["filter"] = arg2 + args["cursor"] = arg0 return args, nil } @@ -1648,22 +1687,14 @@ func (ec *executionContext) field_Query_user_args(ctx context.Context, rawArgs m func (ec *executionContext) field_Repository_accessControlList_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} - var arg0 *int - if tmp, ok := rawArgs["count"]; ok { - arg0, err = ec.unmarshalOInt2ᚖint(ctx, tmp) - if err != nil { - return nil, err - } - } - args["count"] = arg0 - var arg1 *int - if tmp, ok := rawArgs["next"]; ok { - arg1, err = ec.unmarshalOInt2ᚖint(ctx, tmp) + var arg0 *model.Cursor + if tmp, ok := rawArgs["cursor"]; ok { + arg0, err = ec.unmarshalOCursor2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx, tmp) if err != nil { return nil, err } } - args["next"] = arg1 + args["cursor"] = arg0 return args, nil } @@ -1692,22 +1723,14 @@ func (ec *executionContext) field_Repository_file_args(ctx context.Context, rawA func (ec *executionContext) field_Repository_log_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} - var arg0 *string - if tmp, ok := rawArgs["revspec"]; ok { - arg0, err = ec.unmarshalOString2ᚖstring(ctx, tmp) + var arg0 *model.Cursor + if tmp, ok := rawArgs["cursor"]; ok { + arg0, err = ec.unmarshalOCursor2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx, tmp) if err != nil { return nil, err } } - args["revspec"] = arg0 - var arg1 *int - if tmp, ok := rawArgs["count"]; ok { - arg1, err = ec.unmarshalOInt2ᚖint(ctx, tmp) - if err != nil { - return nil, err - } - } - args["count"] = arg1 + args["cursor"] = arg0 return args, nil } @@ -1728,30 +1751,14 @@ func (ec *executionContext) field_Repository_objects_args(ctx context.Context, r func (ec *executionContext) field_Repository_references_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} - var arg0 *int - if tmp, ok := rawArgs["count"]; ok { - arg0, err = ec.unmarshalOInt2ᚖint(ctx, tmp) + var arg0 *model.Cursor + if tmp, ok := rawArgs["cursor"]; ok { + arg0, err = ec.unmarshalOCursor2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx, tmp) if err != nil { return nil, err } } - args["count"] = arg0 - var arg1 *string - if tmp, ok := rawArgs["next"]; ok { - arg1, err = ec.unmarshalOString2ᚖstring(ctx, tmp) - if err != nil { - return nil, err - } - } - args["next"] = arg1 - var arg2 *string - if tmp, ok := rawArgs["glob"]; ok { - arg2, err = ec.unmarshalOString2ᚖstring(ctx, tmp) - if err != nil { - return nil, err - } - } - args["glob"] = arg2 + args["cursor"] = arg0 return args, nil } @@ -1794,22 +1801,14 @@ func (ec *executionContext) field_Repository_tree_args(ctx context.Context, rawA func (ec *executionContext) field_Tree_entries_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} - var arg0 *int - if tmp, ok := rawArgs["count"]; ok { - arg0, err = ec.unmarshalOInt2ᚖint(ctx, tmp) + var arg0 *model.Cursor + if tmp, ok := rawArgs["cursor"]; ok { + arg0, err = ec.unmarshalOCursor2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx, tmp) if err != nil { return nil, err } } - args["count"] = arg0 - var arg1 *string - if tmp, ok := rawArgs["next"]; ok { - arg1, err = ec.unmarshalOString2ᚖstring(ctx, tmp) - if err != nil { - return nil, err - } - } - args["next"] = arg1 + args["cursor"] = arg0 return args, nil } @@ -1830,30 +1829,14 @@ func (ec *executionContext) field_Tree_entry_args(ctx context.Context, rawArgs m func (ec *executionContext) field_User_repositories_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} - var arg0 *int - if tmp, ok := rawArgs["count"]; ok { - arg0, err = ec.unmarshalOInt2ᚖint(ctx, tmp) - if err != nil { - return nil, err - } - } - args["count"] = arg0 - var arg1 *int - if tmp, ok := rawArgs["next"]; ok { - arg1, err = ec.unmarshalOInt2ᚖint(ctx, tmp) + var arg0 *model.Cursor + if tmp, ok := rawArgs["cursor"]; ok { + arg0, err = ec.unmarshalOCursor2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx, tmp) if err != nil { return nil, err } } - args["next"] = arg1 - var arg2 *model.FilterBy - if tmp, ok := rawArgs["filter"]; ok { - arg2, err = ec.unmarshalOFilterBy2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐFilterBy(ctx, tmp) - if err != nil { - return nil, err - } - } - args["filter"] = arg2 + args["cursor"] = arg0 return args, nil } @@ -3194,6 +3177,44 @@ func (ec *executionContext) _Query_me(ctx context.Context, field graphql.Collect return ec.marshalNUser2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐUser(ctx, field.Selections, res) } +func (ec *executionContext) _Query_cursor(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Query_cursor_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().Cursor(rctx, args["filter"].(model.Filter)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*model.Cursor) + fc.Result = res + return ec.marshalOCursor2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx, field.Selections, res) +} + func (ec *executionContext) _Query_user(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -3256,7 +3277,7 @@ func (ec *executionContext) _Query_repositories(ctx context.Context, field graph fc.Args = args resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().Repositories(rctx, args["count"].(*int), args["next"].(*int), args["filter"].(*model.FilterBy)) + return ec.resolvers.Query().Repositories(rctx, args["cursor"].(*model.Cursor)) }) if err != nil { ec.Error(ctx, err) @@ -3790,6 +3811,37 @@ func (ec *executionContext) _Repository_visibility(ctx context.Context, field gr return ec.marshalNVisibility2gitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐVisibility(ctx, field.Selections, res) } +func (ec *executionContext) _Repository_cursor(ctx context.Context, field graphql.CollectedField, obj *model.Repository) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Repository", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Cursor, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*model.Cursor) + fc.Result = res + return ec.marshalOCursor2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx, field.Selections, res) +} + func (ec *executionContext) _Repository_upstreamUrl(ctx context.Context, field graphql.CollectedField, obj *model.Repository) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -3845,7 +3897,7 @@ func (ec *executionContext) _Repository_accessControlList(ctx context.Context, f fc.Args = args resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Repository().AccessControlList(rctx, obj, args["count"].(*int), args["next"].(*int)) + return ec.resolvers.Repository().AccessControlList(rctx, obj, args["cursor"].(*model.Cursor)) }) if err != nil { ec.Error(ctx, err) @@ -3886,7 +3938,7 @@ func (ec *executionContext) _Repository_references(ctx context.Context, field gr fc.Args = args resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Repository().References(rctx, obj, args["count"].(*int), args["next"].(*string), args["glob"].(*string)) + return ec.resolvers.Repository().References(rctx, obj, args["cursor"].(*model.Cursor)) }) if err != nil { ec.Error(ctx, err) @@ -4695,7 +4747,7 @@ func (ec *executionContext) _Tree_entries(ctx context.Context, field graphql.Col fc.Args = args resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return obj.Entries(args["count"].(*int), args["next"].(*string)), nil + return ec.resolvers.Tree().Entries(rctx, obj, args["cursor"].(*model.Cursor)) }) if err != nil { ec.Error(ctx, err) @@ -5207,7 +5259,7 @@ func (ec *executionContext) _User_repositories(ctx context.Context, field graphq fc.Args = args resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.User().Repositories(rctx, obj, args["count"].(*int), args["next"].(*int), args["filter"].(*model.FilterBy)) + return ec.resolvers.User().Repositories(rctx, obj, args["cursor"].(*model.Cursor)) }) if err != nil { ec.Error(ctx, err) @@ -6412,15 +6464,31 @@ func (ec *executionContext) ___Type_ofType(ctx context.Context, field graphql.Co // region **************************** input.gotpl ***************************** -func (ec *executionContext) unmarshalInputFilterBy(ctx context.Context, obj interface{}) (model.FilterBy, error) { - var it model.FilterBy +func (ec *executionContext) unmarshalInputFilter(ctx context.Context, obj interface{}) (model.Filter, error) { + var it model.Filter var asMap = obj.(map[string]interface{}) + if _, present := asMap["count"]; !present { + asMap["count"] = 20 + } + for k, v := range asMap { switch k { + case "count": + var err error + it.Count, err = ec.unmarshalOInt2ᚖint(ctx, v) + if err != nil { + return it, err + } case "search": var err error - it.Search, err = ec.unmarshalNString2string(ctx, v) + it.Search, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + case "orderBy": + var err error + it.OrderBy, err = ec.unmarshalOString2ᚖstring(ctx, v) if err != nil { return it, err } @@ -6892,6 +6960,17 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } return res }) + case "cursor": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_cursor(ctx, field) + return res + }) case "user": field := field out.Concurrently(i, func() (res graphql.Marshaler) { @@ -7051,6 +7130,8 @@ func (ec *executionContext) _Repository(ctx context.Context, sel ast.SelectionSe if out.Values[i] == graphql.Null { atomic.AddUint32(&invalids, 1) } + case "cursor": + out.Values[i] = ec._Repository_cursor(ctx, field, obj) case "upstreamUrl": out.Values[i] = ec._Repository_upstreamUrl(ctx, field, obj) case "accessControlList": @@ -7247,28 +7328,37 @@ func (ec *executionContext) _Tree(ctx context.Context, sel ast.SelectionSet, obj case "type": out.Values[i] = ec._Tree_type(ctx, field, obj) if out.Values[i] == graphql.Null { - invalids++ + atomic.AddUint32(&invalids, 1) } case "id": out.Values[i] = ec._Tree_id(ctx, field, obj) if out.Values[i] == graphql.Null { - invalids++ + atomic.AddUint32(&invalids, 1) } case "shortId": out.Values[i] = ec._Tree_shortId(ctx, field, obj) if out.Values[i] == graphql.Null { - invalids++ + atomic.AddUint32(&invalids, 1) } case "raw": out.Values[i] = ec._Tree_raw(ctx, field, obj) if out.Values[i] == graphql.Null { - invalids++ + atomic.AddUint32(&invalids, 1) } case "entries": - out.Values[i] = ec._Tree_entries(ctx, field, obj) - if out.Values[i] == graphql.Null { - invalids++ - } + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Tree_entries(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "entry": out.Values[i] = ec._Tree_entry(ctx, field, obj) default: @@ -7875,6 +7965,10 @@ func (ec *executionContext) marshalNEntity2gitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsr return ec._Entity(ctx, sel, v) } +func (ec *executionContext) unmarshalNFilter2gitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐFilter(ctx context.Context, v interface{}) (model.Filter, error) { + return ec.unmarshalInputFilter(ctx, v) +} + func (ec *executionContext) unmarshalNID2string(ctx context.Context, v interface{}) (string, error) { return graphql.UnmarshalID(v) } @@ -8518,18 +8612,30 @@ func (ec *executionContext) marshalOCommit2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgit return ec._Commit(ctx, sel, v) } -func (ec *executionContext) unmarshalOFilterBy2gitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐFilterBy(ctx context.Context, v interface{}) (model.FilterBy, error) { - return ec.unmarshalInputFilterBy(ctx, v) +func (ec *executionContext) unmarshalOCursor2gitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx context.Context, v interface{}) (model.Cursor, error) { + var res model.Cursor + return res, res.UnmarshalGQL(v) +} + +func (ec *executionContext) marshalOCursor2gitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx context.Context, sel ast.SelectionSet, v model.Cursor) graphql.Marshaler { + return v } -func (ec *executionContext) unmarshalOFilterBy2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐFilterBy(ctx context.Context, v interface{}) (*model.FilterBy, error) { +func (ec *executionContext) unmarshalOCursor2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx context.Context, v interface{}) (*model.Cursor, error) { if v == nil { return nil, nil } - res, err := ec.unmarshalOFilterBy2gitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐFilterBy(ctx, v) + res, err := ec.unmarshalOCursor2gitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx, v) return &res, err } +func (ec *executionContext) marshalOCursor2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐCursor(ctx context.Context, sel ast.SelectionSet, v *model.Cursor) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + func (ec *executionContext) unmarshalOInt2int(ctx context.Context, v interface{}) (int, error) { return graphql.UnmarshalInt(v) } diff --git a/api/graph/model/cursor.go b/api/graph/model/cursor.go new file mode 100644 index 0000000..cf945fd --- /dev/null +++ b/api/graph/model/cursor.go @@ -0,0 +1,41 @@ +package model + +import ( + "encoding/json" + "fmt" + "io" + + "git.sr.ht/~sircmpwn/git.sr.ht/api/crypto" +) + +type Cursor struct { + Count int `json:"count"` + Next string `json:"next"` + OrderBy string `json:"order_by"` + Search string `json:"search"` +} + +func (cur *Cursor) UnmarshalGQL(v interface{}) error { + enc, ok := v.(string) + if !ok { + return fmt.Errorf("cursor must be strings") + } + plain := crypto.Decrypt([]byte(enc)) + if plain == nil { + return fmt.Errorf("Invalid cursor") + } + err := json.Unmarshal(plain, cur) + if err != nil { + // This is guaranteed to be a programming error + panic(err) + } + return nil +} + +func (cur Cursor) MarshalGQL(w io.Writer) { + data, err := json.Marshal(cur) + if err != nil { + panic(err) + } + w.Write(crypto.Encrypt(data)) +} diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go index baf58b3..d44c166 100644 --- a/api/graph/model/models_gen.go +++ b/api/graph/model/models_gen.go @@ -31,8 +31,10 @@ type Artifact struct { URL string `json:"url"` } -type FilterBy struct { - Search string `json:"search"` +type Filter struct { + Count *int `json:"count"` + Search *string `json:"search"` + OrderBy *string `json:"orderBy"` } type RepoInput struct { diff --git a/api/graph/model/repository.go b/api/graph/model/repository.go index 86b9056..3dff4b6 100644 --- a/api/graph/model/repository.go +++ b/api/graph/model/repository.go @@ -17,6 +17,7 @@ type Repository struct { Name string `json:"name"` Description *string `json:"description"` Visibility Visibility `json:"visibility"` + Cursor *Cursor `json:"cursor"` UpstreamURL *string `json:"upstreamUrl"` Objects []Object `json:"objects"` Log []*Commit `json:"log"` diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index 538f91b..fa5d7b4 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -1,3 +1,4 @@ +scalar Cursor scalar Time scalar Upload @@ -40,7 +41,7 @@ interface Entity { canonicalName: String! # A list of repositories owned by this entity - repositories(count: Int = 10, next: Int, filter: FilterBy): [Repository]! + repositories(cursor: Cursor): [Repository]! } # A registered user @@ -56,7 +57,7 @@ type User implements Entity { bio: String # A list of repositories owned by this user - repositories(count: Int = 10, next: Int, filter: FilterBy): [Repository]! + repositories(cursor: Cursor): [Repository]! } # A git repository @@ -69,12 +70,15 @@ type Repository { description: String visibility: Visibility! + # Pass this into the same endpoint to receive a new page of results + cursor: Cursor + # If this repository was cloned from another, this will be set to the # original clone URL upstreamUrl: String # Returns access control list entries for this repository - accessControlList(count: Int = 10, next: Int): [ACL]! + accessControlList(cursor: Cursor): [ACL]! ## Plumbing API: @@ -82,7 +86,7 @@ type Repository { # # glob: an optional string to filter the list of references, e.g. for tags # use "refs/tags/*", or leave null to enumerate all references - references(count: Int = 10, next: String, glob: String): [Reference]! + references(cursor: Cursor): [Reference]! # Returns a list of objects for this repository by their IDs (using fully # qualified git object IDs, 40 character hex strings) @@ -97,8 +101,9 @@ type Repository { # The HEAD reference for this repository (equivalent to the default branch) HEAD: Reference - # Returns a list of comments, starting from revspec - log(revspec: String = "HEAD", count: Int = 10): [Commit]! + # Returns a list of comments in topological order. `cursor.from` is used as + # the revspec to begin logging from. + log(cursor: Cursor): [Commit]! # Returns the tree for a given revspec # @@ -178,7 +183,7 @@ type Tree implements Object { shortId: String! raw: String! # TODO: add globbing - entries(count: Int = 100, next: String): [TreeEntry!]! + entries(cursor: Cursor): [TreeEntry!]! entry(path: String): TreeEntry } @@ -228,10 +233,16 @@ type Tag implements Object { message: String } -# Specifies filtering criteria for a listing query -input FilterBy { - # Same search syntax as searching on the web UI - search: String! +input Filter { + # Number of results to return. + count: Int = 20 + + # Search terms. The exact meaning varies by usage, but generally these are + # compatible with the web UI's search syntax. + search: String + + # Field to order results by, if possible. + orderBy: String } type Query { @@ -241,16 +252,20 @@ type Query { # Returns the authenticated user me: User! + # Returns a custom cursor based on a given filter criteria + cursor(filter: Filter!): Cursor + # Returns a specific user user(username: String!): User # Returns repositories that the authenticated user has access to + # # NOTE: in this version of the API, only repositories owned by the # authenticated user are returned, but in the future the default behavior # will be to return all repositories that the user either (1) has been given # explicit access to via ACLs or (2) has implicit access to either by # ownership or group membership. - repositories(count: Int = 10, next: Int, filter: FilterBy): [Repository]! + repositories(cursor: Cursor): [Repository]! # Returns a specific repository repository(id: Int!): Repository diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index 5e8130c..0d045d6 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -70,11 +70,15 @@ func (r *queryResolver) Me(ctx context.Context) (*model.User, error) { }, nil } +func (r *queryResolver) Cursor(ctx context.Context, filter model.Filter) (*model.Cursor, error) { + panic(fmt.Errorf("not implemented")) +} + func (r *queryResolver) User(ctx context.Context, username string) (*model.User, error) { return loaders.ForContext(ctx).UsersByName.Load(username) } -func (r *queryResolver) Repositories(ctx context.Context, count *int, next *int, filter *model.FilterBy) ([]*model.Repository, error) { +func (r *queryResolver) Repositories(ctx context.Context, cursor *model.Cursor) ([]*model.Repository, error) { var ( err error rows *sql.Rows @@ -85,18 +89,7 @@ func (r *queryResolver) Repositories(ctx context.Context, count *int, next *int, From(`repository repo`). Where(`repo.owner_id = ?`, auth.ForContext(ctx).ID). OrderBy(`repo.id DESC`). - Limit(uint64(*count)) - if next != nil { - query = query.Where(`repo.id < ?`, *next) - } - if filter != nil { - searchable, _ := repo.(database.Searchable) - query, err = database.ApplyFilter(query, searchable, filter.Search) - if err != nil { - return nil, err - } - } - + Limit(25) if rows, err = query.RunWith(r.DB).QueryContext(ctx); err != nil { panic(err) } @@ -135,11 +128,11 @@ func (r *repositoryResolver) Owner(ctx context.Context, obj *model.Repository) ( return loaders.ForContext(ctx).UsersByID.Load(obj.OwnerID) } -func (r *repositoryResolver) AccessControlList(ctx context.Context, obj *model.Repository, count *int, next *int) ([]*model.ACL, error) { +func (r *repositoryResolver) AccessControlList(ctx context.Context, obj *model.Repository, cursor *model.Cursor) ([]*model.ACL, error) { panic(fmt.Errorf("not implemented")) } -func (r *repositoryResolver) References(ctx context.Context, obj *model.Repository, count *int, next *string, glob *string) ([]*model.Reference, error) { +func (r *repositoryResolver) References(ctx context.Context, obj *model.Repository, cursor *model.Cursor) ([]*model.Reference, error) { iter, err := obj.Repo().References() if err != nil { return nil, err @@ -154,24 +147,17 @@ func (r *repositoryResolver) References(ctx context.Context, obj *model.Reposito sort.SliceStable(refs, func(i, j int) bool { return refs[i].Name() < refs[j].Name() }) - if next != nil { - for i, ref := range refs { - if ref.Name() == *next { - refs = refs[i+1:] - if len(refs) > *count { - refs = refs[:*count] - } - return refs, nil - } - } - } - if len(refs) > *count { - refs = refs[:*count] + if len(refs) > 25 { + refs = refs[:25] } return refs, nil } -func (r *userResolver) Repositories(ctx context.Context, obj *model.User, count *int, next *int, filter *model.FilterBy) ([]*model.Repository, error) { +func (r *treeResolver) Entries(ctx context.Context, obj *model.Tree, cursor *model.Cursor) ([]*model.TreeEntry, error) { + panic(fmt.Errorf("not implemented")) +} + +func (r *userResolver) Repositories(ctx context.Context, obj *model.User, cursor *model.Cursor) ([]*model.Repository, error) { var ( err error rows *sql.Rows @@ -182,17 +168,7 @@ func (r *userResolver) Repositories(ctx context.Context, obj *model.User, count From(`repository repo`). Where(`repo.owner_id = ?`, obj.ID). OrderBy(`id DESC`). - Limit(uint64(*count)) - if next != nil { - query = query.Where(`repo.id < ?`, *next) - } - if filter != nil { - searchable, _ := repo.(database.Searchable) - query, err = database.ApplyFilter(query, searchable, filter.Search) - if err != nil { - return nil, err - } - } + Limit(25) if rows, err = query.RunWith(r.DB).QueryContext(ctx); err != nil { panic(err) } @@ -217,10 +193,24 @@ func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} } // Repository returns generated.RepositoryResolver implementation. func (r *Resolver) Repository() generated.RepositoryResolver { return &repositoryResolver{r} } +// Tree returns generated.TreeResolver implementation. +func (r *Resolver) Tree() generated.TreeResolver { return &treeResolver{r} } + // User returns generated.UserResolver implementation. func (r *Resolver) User() generated.UserResolver { return &userResolver{r} } type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } type repositoryResolver struct{ *Resolver } +type treeResolver struct{ *Resolver } type userResolver struct{ *Resolver } + +// !!! WARNING !!! +// The code below was going to be deleted when updating resolvers. It has been copied here so you have +// one last chance to move it out of harms way if you want. There are two reasons this happens: +// - When renaming or deleting a resolver the old code will be put in here. You can safely delete +// it when you're done. +// - You have helper methods in this file. Move them out to keep these resolver files clean. +func (r *repositoryResolver) Cursor(ctx context.Context, obj *model.Repository) (*model.Cursor, error) { + panic(fmt.Errorf("not implemented")) +} diff --git a/api/server.go b/api/server.go index 9602a92..f8b263f 100644 --- a/api/server.go +++ b/api/server.go @@ -15,6 +15,7 @@ import ( "github.com/vaughan0/go-ini" "git.sr.ht/~sircmpwn/git.sr.ht/api/auth" + "git.sr.ht/~sircmpwn/git.sr.ht/api/crypto" "git.sr.ht/~sircmpwn/git.sr.ht/api/graph" "git.sr.ht/~sircmpwn/git.sr.ht/api/graph/generated" "git.sr.ht/~sircmpwn/git.sr.ht/api/loaders" @@ -49,6 +50,8 @@ func main() { log.Fatalf("Failed to load config file: %v", err) } + crypto.InitCrypto(config) + pgcs, ok := config.Get("git.sr.ht", "connection-string") if !ok { log.Fatalf("No connection string configured for git.sr.ht: %v", err) -- 2.38.4