git.sr.ht/api/graph/schema.resolvers.go

1511 lines
43 KiB
Go

package graph
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
import (
"bytes"
"context"
"crypto/md5"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"sort"
"strconv"
"strings"
"git.sr.ht/~sircmpwn/core-go/auth"
"git.sr.ht/~sircmpwn/core-go/config"
"git.sr.ht/~sircmpwn/core-go/database"
coremodel "git.sr.ht/~sircmpwn/core-go/model"
"git.sr.ht/~sircmpwn/core-go/server"
"git.sr.ht/~sircmpwn/core-go/valid"
corewebhooks "git.sr.ht/~sircmpwn/core-go/webhooks"
"git.sr.ht/~sircmpwn/git.sr.ht/api/account"
"git.sr.ht/~sircmpwn/git.sr.ht/api/graph/api"
"git.sr.ht/~sircmpwn/git.sr.ht/api/graph/model"
"git.sr.ht/~sircmpwn/git.sr.ht/api/loaders"
"git.sr.ht/~sircmpwn/git.sr.ht/api/repos"
"git.sr.ht/~sircmpwn/git.sr.ht/api/webhooks"
"github.com/99designs/gqlgen/graphql"
sq "github.com/Masterminds/squirrel"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/storer"
"github.com/lib/pq"
minio "github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// Repository is the resolver for the repository field.
func (r *aCLResolver) Repository(ctx context.Context, obj *model.ACL) (*model.Repository, error) {
return loaders.ForContext(ctx).RepositoriesByID.Load(obj.RepoID)
}
// Entity is the resolver for the entity field.
func (r *aCLResolver) Entity(ctx context.Context, obj *model.ACL) (model.Entity, error) {
return loaders.ForContext(ctx).UsersByID.Load(obj.UserID)
}
// URL is the resolver for the url field.
func (r *artifactResolver) URL(ctx context.Context, obj *model.Artifact) (string, error) {
conf := config.ForContext(ctx)
upstream, ok := conf.Get("objects", "s3-upstream")
if !ok {
return "", fmt.Errorf("S3 upstream not configured for this server")
}
bucket, ok := conf.Get("git.sr.ht", "s3-bucket")
if !ok {
return "", fmt.Errorf("S3 bucket not configured for this server")
}
prefix, ok := conf.Get("git.sr.ht", "s3-prefix")
if !ok {
return "", fmt.Errorf("S3 prefix not configured for this server")
}
return fmt.Sprintf("https://%s/%s/%s/%s", upstream, bucket, prefix, obj.Filename), nil
}
// Diff is the resolver for the diff field.
func (r *commitResolver) Diff(ctx context.Context, obj *model.Commit) (string, error) {
return obj.DiffContext(ctx), nil
}
// CreateRepository is the resolver for the createRepository field.
func (r *mutationResolver) CreateRepository(ctx context.Context, name string, visibility model.Visibility, description *string, cloneURL *string) (*model.Repository, error) {
if !repoNameRE.MatchString(name) {
return nil, valid.Errorf(ctx, "name", "Invalid repository name '%s' (must match %s)",
name, repoNameRE.String())
}
if name == "." || name == ".." {
return nil, valid.Errorf(ctx, "name", "Invalid repository name '%s' (must not be . or ..)", name)
}
if name == ".git" || name == ".hg" {
return nil, valid.Errorf(ctx, "name", "Invalid repository name '%s' (must not be .git or .hg)", name)
}
conf := config.ForContext(ctx)
repoStore, ok := conf.Get("git.sr.ht", "repos")
if !ok || repoStore == "" {
panic(fmt.Errorf("Configuration error: [git.sr.ht]repos is unset"))
}
postUpdate, ok := conf.Get("git.sr.ht", "post-update-script")
if !ok {
panic(fmt.Errorf("Configuration error: [git.sr.ht]post-update is unset"))
}
user := auth.ForContext(ctx)
repoPath := path.Join(repoStore, "~"+user.Username, name)
var (
repoCreated bool
repo model.Repository
gitrepo *git.Repository
)
defer func() {
if err := recover(); err != nil {
if repoCreated {
err := os.RemoveAll(repoPath)
if err != nil {
panic(err)
}
}
panic(err)
}
}()
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
cloneStatus := CloneNone
if cloneURL != nil {
cloneStatus = CloneInProgress
}
row := tx.QueryRowContext(ctx, `
INSERT INTO repository (
created, updated, name, description, path, visibility, owner_id,
clone_status
) VALUES (
NOW() at time zone 'utc',
NOW() at time zone 'utc',
$1, $2, $3, $4, $5, $6
) RETURNING
id, created, updated, name, description, visibility,
path, owner_id;
`, name, description, repoPath, visibility, user.UserID, cloneStatus)
if err := row.Scan(&repo.ID, &repo.Created, &repo.Updated, &repo.Name,
&repo.Description, &repo.Visibility,
&repo.Path, &repo.OwnerID); err != nil {
if strings.Contains(err.Error(), "duplicate key value violates unique constraint") {
return valid.Error(ctx, "name", "A repository with this name already exists.")
}
return err
}
var err error
gitrepo, err = git.PlainInit(repoPath, true)
if err != nil {
return err
}
repoCreated = true
gitconfig, err := gitrepo.Config()
if err != nil {
return err
}
gitconfig.Raw.SetOption("core", "", "repositoryformatversion", "0")
gitconfig.Raw.SetOption("core", "", "filemode", "true")
gitconfig.Raw.SetOption("srht", "", "repo-id", strconv.Itoa(repo.ID))
gitconfig.Raw.SetOption("receive", "", "denyDeleteCurrent", "ignore")
gitconfig.Raw.SetOption("receive", "", "advertisePushOptions", "true")
if err := gitrepo.Storer.SetConfig(gitconfig); err != nil {
return err
}
hookdir := path.Join(repoPath, "hooks")
if err := os.Mkdir(hookdir, os.ModePerm); err != nil {
return err
}
for _, hook := range []string{"pre-receive", "update", "post-update"} {
if err := os.Symlink(postUpdate, path.Join(hookdir, hook)); err != nil {
return err
}
}
export := path.Join(repoPath, "git-daemon-export-ok")
if repo.Visibility != model.VisibilityPrivate {
f, err := os.Create(export)
if err != nil {
return err
}
defer f.Close()
}
if cloneURL != nil {
u, err := url.Parse(*cloneURL)
if err != nil {
return valid.Errorf(ctx, "cloneUrl", "Invalid clone URL: %s", err)
} else if u.Host == "" {
return valid.Error(ctx, "cloneUrl", "Cannot use URL without host")
} else if _, ok := allowedCloneSchemes[u.Scheme]; !ok {
return valid.Errorf(ctx, "cloneUrl", "Unsupported protocol %q", u.Scheme)
}
origin := config.GetOrigin(conf, "git.sr.ht", true)
o, err := url.Parse(origin)
if err != nil {
panic(err)
}
// Check if this is a local repository
if u.Scheme == o.Scheme && u.Host == o.Host {
u.Path = strings.TrimPrefix(u.Path, "/")
split := strings.SplitN(u.Path, "/", 2)
if len(split) != 2 {
return valid.Error(ctx, "cloneUrl", "Invalid clone URL")
}
canonicalName, repoName := split[0], split[1]
entity := canonicalName
if strings.HasPrefix(entity, "~") {
entity = entity[1:]
} else {
return valid.Error(ctx, "cloneUrl", "Invalid username")
}
repo, err := loaders.ForContext(ctx).
RepositoriesByOwnerRepoName.Load(loaders.OwnerRepoName{entity, repoName})
if err != nil {
panic(err)
} else if repo == nil {
return valid.Error(ctx, "cloneUrl", "Repository not found")
}
cloneURL = &repo.Path
}
}
webhooks.DeliverRepoEvent(ctx, model.WebhookEventRepoCreated, &repo)
webhooks.DeliverLegacyRepoCreate(ctx, &repo)
return nil
}); err != nil {
if repoCreated {
err := os.RemoveAll(repoPath)
if err != nil {
panic(err)
}
}
return nil, err
}
// Schedule the clone after the transaction has committed to avoid race
// conditions for repository.clone_status.
if cloneURL != nil {
repos.Clone(ctx, repo.ID, gitrepo, *cloneURL)
}
return &repo, nil
}
// UpdateRepository is the resolver for the updateRepository field.
func (r *mutationResolver) UpdateRepository(ctx context.Context, id int, input map[string]interface{}) (*model.Repository, error) {
// This function is somewhat involved, because it has to address repository
// renames. The process is the following:
//
// 1. Prepare, but don't execute, a normal update operation
// 2. If a rename is requested, insert a redirect and move the repo on disk.
// 3. Complete the normal update operation.
//
// We need 2 to happen between 1 and 3 because step 3 is going to change
// information in the database that step 2 relies on, and step 3 has to
// happen after step 2 because step 2 will change something (the path) that
// step 3 relies on.
//
// Additional complication: if an error occurs after the directory has been
// moved on disk, we have to move it back.
var (
repo model.Repository
origPath string
repoPath string
moved bool
)
defer func() {
if err := recover(); err != nil {
if moved {
err := os.Rename(repoPath, origPath)
if err != nil {
panic(err)
}
}
panic(err)
}
}()
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
user := auth.ForContext(ctx)
query := sq.Update(repo.Table()).
PlaceholderFormat(sq.Dollar)
validation := valid.New(ctx).WithInput(input)
validation.OptionalString("name", func(name string) {
validation.Expect(repoNameRE.MatchString(name),
"Invalid repository name '%s' (must match %s)",
name, repoNameRE.String()).
WithField("name")
validation.Expect(name != "." && name != "..",
"Invalid repository name '%s' (must not be . or ..)", name).
WithField("name")
validation.Expect(name != ".git" && name != ".hg",
"Invalid repository name '%s' (must not be .git or .hg)", name).
WithField("name")
if !validation.Ok() {
return
}
var origPath string
row := tx.QueryRowContext(ctx, `
INSERT INTO redirect (
created, name, path, owner_id, new_repo_id
) SELECT
NOW() at time zone 'utc',
orig.name, orig.path, orig.owner_id, orig.id
FROM repository orig
WHERE id = $1 AND owner_id = $2
RETURNING path;
`, id, auth.ForContext(ctx).UserID)
if err := row.Scan(&origPath); err != nil {
if err == sql.ErrNoRows {
validation.Error("No repository by ID %d found for this user", id)
return
}
validation.Error("%s", err.Error())
return
}
conf := config.ForContext(ctx)
repoStore, ok := conf.Get("git.sr.ht", "repos")
if !ok || repoStore == "" {
panic(fmt.Errorf("Configuration error: [git.sr.ht]repos is unset"))
}
repoPath := path.Join(repoStore, "~"+user.Username, name)
err := os.Rename(origPath, repoPath)
if errors.Is(err, os.ErrExist) {
validation.Error("A repository with this name already exists.").
WithField("name")
return
} else if err != nil {
validation.Error("%s", err.Error())
return
}
moved = true
query = query.Set(`name`, name)
query = query.Set(`path`, repoPath)
})
validation.NullableString("description", func(description *string) {
if description == nil {
query = query.Set(`description`, nil)
} else {
query = query.Set(`description`, *description)
}
})
validation.OptionalString("visibility", func(vis string) {
query = query.Set(`visibility`, vis)
})
validation.NullableString("readme", func(readme *string) {
if readme == nil {
query = query.Set(`readme`, nil)
} else {
query = query.Set(`readme`, *readme)
}
})
if !validation.Ok() {
return errors.New("placeholder") // TODO: Avoid surfacing placeholder error
}
query = query.
Where(`id = ?`, id).
Where(`owner_id = ?`, auth.ForContext(ctx).UserID).
Set(`updated`, sq.Expr(`now() at time zone 'utc'`)).
Suffix(`RETURNING
id, created, updated, name, description, visibility,
path, owner_id`)
row := query.RunWith(tx).QueryRowContext(ctx)
if err := row.Scan(&repo.ID, &repo.Created, &repo.Updated,
&repo.Name, &repo.Description, &repo.Visibility,
&repo.Path, &repo.OwnerID); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("No repository by ID %d found for this user", id)
}
return err
}
// This must be done after the query so that repo.Path is populated
validation.OptionalString("HEAD", func(ref string) {
gitRepo := repo.Repo()
gitRepo.Lock()
defer gitRepo.Unlock()
branchName := plumbing.NewBranchReferenceName(ref)
// Make sure that the branch exists
branch, err := gitRepo.Storer.Reference(branchName)
if err != nil {
validation.Error("%s", err.Error()).WithField("HEAD")
return
}
head := plumbing.NewSymbolicReference(plumbing.HEAD, branch.Name())
if err := gitRepo.Storer.SetReference(head); err != nil {
validation.Error("%s", err.Error()).WithField("HEAD")
return
}
})
if !validation.Ok() {
return errors.New("placeholder") // TODO: Avoid surfacing placeholder error
}
export := path.Join(repo.Path, "git-daemon-export-ok")
if repo.Visibility == model.VisibilityPrivate {
err := os.Remove(export)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
} else {
f, err := os.Create(export)
if err != nil {
return err
}
defer f.Close()
}
webhooks.DeliverRepoEvent(ctx, model.WebhookEventRepoUpdate, &repo)
webhooks.DeliverLegacyRepoUpdate(ctx, &repo)
return nil
}); err != nil {
if moved && err != nil {
err := os.Rename(repoPath, origPath)
if err != nil {
panic(err)
}
}
return nil, err
}
return &repo, nil
}
// DeleteRepository is the resolver for the deleteRepository field.
func (r *mutationResolver) DeleteRepository(ctx context.Context, id int) (*model.Repository, error) {
var repo model.Repository
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
rows, err := tx.QueryContext(ctx,
`DELETE FROM artifacts WHERE repo_id = $1 RETURNING filename;`, id)
if err != nil {
return err
}
var artifacts []string
for rows.Next() {
var filename string
if err := rows.Scan(&filename); err != nil {
return err
}
artifacts = append(artifacts, filename)
}
if err := rows.Err(); err != nil {
return err
}
row := tx.QueryRowContext(ctx, `
DELETE FROM repository
WHERE id = $1 AND owner_id = $2
RETURNING
id, created, updated, name, description, visibility,
path, owner_id;
`, id, auth.ForContext(ctx).UserID)
if err := row.Scan(&repo.ID, &repo.Created, &repo.Updated,
&repo.Name, &repo.Description, &repo.Visibility,
&repo.Path, &repo.OwnerID); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("No repository by ID %d found for this user", id)
}
return err
}
webhooks.DeliverRepoEvent(ctx, model.WebhookEventRepoDeleted, &repo)
webhooks.DeliverLegacyRepoDeleted(ctx, &repo)
if err := os.RemoveAll(repo.Path); err != nil {
return err
}
if len(artifacts) > 0 {
username := auth.ForContext(ctx).Username
repos.DeleteArtifacts(ctx, username, repo.Name, artifacts)
}
return nil
}); err != nil {
return nil, err
}
return &repo, nil
}
// UpdateACL is the resolver for the updateACL field.
func (r *mutationResolver) UpdateACL(ctx context.Context, repoID int, mode model.AccessMode, entity string) (*model.ACL, error) {
if len(entity) == 0 || entity[0] != '~' {
return nil, fmt.Errorf("Unknown entity '%s'", entity)
}
entity = entity[1:]
if entity == auth.ForContext(ctx).Username {
return nil, fmt.Errorf("Cannot edit your own access modes")
}
var acl model.ACL
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `
WITH grantee AS (
SELECT u.id uid, repo.id rid
FROM "user" u, repository repo
WHERE u.username = $3 AND repo.id = $1 AND repo.owner_id = $2
)
INSERT INTO access (created, updated, mode, user_id, repo_id)
SELECT
NOW() at time zone 'utc', NOW() at time zone 'utc',
$4, grantee.uid, grantee.rid
FROM grantee
ON CONFLICT ON CONSTRAINT uq_access_user_id_repo_id
DO UPDATE SET mode = $4, updated = NOW() at time zone 'utc'
RETURNING id, created, mode, repo_id, user_id;`,
repoID, auth.ForContext(ctx).UserID,
entity, strings.ToLower(string(mode)))
if err := row.Scan(&acl.ID, &acl.Created, &acl.RawAccessMode,
&acl.RepoID, &acl.UserID); err != nil {
if err == sql.ErrNoRows {
// TODO: Fetch user details from meta.sr.ht
return fmt.Errorf("No such repository or user found")
}
return err
}
return nil
}); err != nil {
return nil, err
}
return &acl, nil
}
// DeleteACL is the resolver for the deleteACL field.
func (r *mutationResolver) DeleteACL(ctx context.Context, id int) (*model.ACL, error) {
var acl model.ACL
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `
DELETE FROM access
USING repository repo
WHERE repo_id = repo.id AND repo.owner_id = $1 AND access.id = $2
RETURNING access.id, access.created, mode, repo_id, user_id;
`, auth.ForContext(ctx).UserID, id)
if err := row.Scan(&acl.ID, &acl.Created, &acl.RawAccessMode,
&acl.RepoID, &acl.UserID); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("No such repository or ACL entry found")
}
return err
}
return nil
}); err != nil {
return nil, err
}
return &acl, nil
}
// UploadArtifact is the resolver for the uploadArtifact field.
func (r *mutationResolver) UploadArtifact(ctx context.Context, repoID int, revspec string, file graphql.Upload) (*model.Artifact, error) {
conf := config.ForContext(ctx)
upstream, _ := conf.Get("objects", "s3-upstream")
accessKey, _ := conf.Get("objects", "s3-access-key")
secretKey, _ := conf.Get("objects", "s3-secret-key")
bucket, _ := conf.Get("git.sr.ht", "s3-bucket")
prefix, _ := conf.Get("git.sr.ht", "s3-prefix")
if upstream == "" || accessKey == "" || secretKey == "" || bucket == "" {
return nil, fmt.Errorf("Object storage is not enabled for this server")
}
mc, err := minio.New(upstream, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: true,
})
if err != nil {
panic(err)
}
repo, err := loaders.ForContext(ctx).RepositoriesByID.Load(repoID)
if err != nil {
return nil, fmt.Errorf("Repository %d not found", repoID)
}
if repo.OwnerID != auth.ForContext(ctx).UserID {
return nil, fmt.Errorf("Access denied for repo %d", repoID)
}
gitRepo := repo.Repo()
gitRepo.Lock()
hash, err := gitRepo.ResolveRevision(plumbing.Revision(revspec))
gitRepo.Unlock()
if err != nil {
return nil, err
}
s3path := path.Join(prefix, "artifacts",
"~"+auth.ForContext(ctx).Username, repo.Name, file.Filename)
err = mc.MakeBucket(ctx, bucket, minio.MakeBucketOptions{})
if s3err, ok := err.(minio.ErrorResponse); !ok ||
(s3err.Code != "BucketAlreadyExists" && s3err.Code != "BucketAlreadyOwnedByYou") {
panic(err)
}
core := minio.Core{mc}
uid, err := core.NewMultipartUpload(ctx, bucket,
s3path, minio.PutObjectOptions{
ContentType: "application/octet-stream",
})
if err != nil {
return nil, err
}
defer func() {
if err := recover(); err != nil {
core.AbortMultipartUpload(context.Background(), bucket, s3path, uid)
panic(err)
}
}()
var artifact model.Artifact
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
// To guarantee atomicity, we do the following:
// 1. Upload all parts as an S3 multipart upload, but don't complete
// it. This computes the SHA-256 as a side-effect.
// 2. Insert row into the database and let PostgreSQL prevent
// duplicates with constraints. Once this finishes we know we are the
// exclusive writer of this artifact.
// 3. Complete the S3 multi-part upload.
sha := sha256.New()
reader := io.TeeReader(file.File, sha)
var (
partNum int = 1
parts []minio.CompletePart
)
for {
var data [134217728]byte // 128 MiB
n, err := reader.Read(data[:])
if errors.Is(err, io.EOF) {
if n == 0 {
break
}
// Write data[:n] and go for another loop before quitting
} else if err != nil {
return err
}
md5sum := md5.Sum(data[:n])
md5b64 := base64.StdEncoding.EncodeToString(md5sum[:])
shasum := sha256.Sum256(data[:n])
shahex := hex.EncodeToString(shasum[:])
opart, err := core.PutObjectPart(ctx, bucket, s3path,
uid, partNum, bytes.NewReader(data[:n]), int64(n),
md5b64, shahex, nil)
if err != nil {
return err
}
parts = append(parts, minio.CompletePart{
PartNumber: partNum,
ETag: opart.ETag,
})
partNum++
}
checksum := "sha256:" + hex.EncodeToString(sha.Sum(nil))
row := tx.QueryRowContext(ctx, `
INSERT INTO artifacts (
created, user_id, repo_id, commit, filename, checksum, size
) VALUES (
NOW() at time zone 'utc',
$1, $2, $3, $4, $5, $6
) RETURNING id, created, filename, checksum, size, commit`,
repo.OwnerID, repo.ID, hash.String(), file.Filename,
checksum, file.Size)
if err := row.Scan(&artifact.ID, &artifact.Created, &artifact.Filename,
&artifact.Checksum, &artifact.Size, &artifact.Commit); err != nil {
if err.Error() == "pq: duplicate key value violates unique constraint \"repo_artifact_filename_unique\"" {
return fmt.Errorf("An artifact by this name already exists for this repository (artifact names must be unique for within each repository)")
}
return err
}
_, err := core.CompleteMultipartUpload(ctx, bucket, s3path, uid, parts)
return err
}); err != nil {
if err != nil {
core.AbortMultipartUpload(context.Background(), bucket, s3path, uid)
}
return nil, err
}
return &artifact, nil
}
// DeleteArtifact is the resolver for the deleteArtifact field.
func (r *mutationResolver) DeleteArtifact(ctx context.Context, id int) (*model.Artifact, error) {
conf := config.ForContext(ctx)
upstream, _ := conf.Get("objects", "s3-upstream")
accessKey, _ := conf.Get("objects", "s3-access-key")
secretKey, _ := conf.Get("objects", "s3-secret-key")
bucket, _ := conf.Get("git.sr.ht", "s3-bucket")
prefix, _ := conf.Get("git.sr.ht", "s3-prefix")
if upstream == "" || accessKey == "" || secretKey == "" || bucket == "" {
return nil, fmt.Errorf("Object storage is not enabled for this server")
}
mc, err := minio.New(upstream, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: true,
})
if err != nil {
panic(err)
}
var artifact model.Artifact
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
var repoName string
row := tx.QueryRowContext(ctx, `
DELETE FROM artifacts
USING repository repo
WHERE
repo_id = repo.id AND
repo.owner_id = $1 AND
artifacts.id = $2
RETURNING
artifacts.id, artifacts.created, filename, checksum,
size, commit, repo.name;`,
auth.ForContext(ctx).UserID, id)
if err := row.Scan(&artifact.ID, &artifact.Created, &artifact.Filename,
&artifact.Checksum, &artifact.Size, &artifact.Commit,
&repoName); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("No such artifact for this user")
}
return err
}
s3path := path.Join(prefix, "artifacts",
"~"+auth.ForContext(ctx).Username, repoName, artifact.Filename)
return mc.RemoveObject(ctx, bucket, s3path, minio.RemoveObjectOptions{})
}); err != nil {
return nil, err
}
return &artifact, nil
}
// CreateUserWebhook is the resolver for the createUserWebhook field.
func (r *mutationResolver) CreateUserWebhook(ctx context.Context, config model.UserWebhookInput) (model.WebhookSubscription, error) {
schema := server.ForContext(ctx).Schema
if err := corewebhooks.Validate(schema, config.Query); err != nil {
return nil, err
}
user := auth.ForContext(ctx)
ac, err := corewebhooks.NewAuthConfig(ctx)
if err != nil {
return nil, err
}
var sub model.UserWebhookSubscription
if len(config.Events) == 0 {
return nil, fmt.Errorf("Must specify at least one event")
}
events := make([]string, len(config.Events))
for i, ev := range config.Events {
events[i] = ev.String()
// TODO: gqlgen does not support doing anything useful with directives
// on enums at the time of writing, so we have to do a little bit of
// manual fuckery
var access string
switch ev {
case model.WebhookEventRepoCreated, model.WebhookEventRepoUpdate,
model.WebhookEventRepoDeleted:
access = "REPOSITORIES"
default:
return nil, fmt.Errorf("Unsupported event %s", ev.String())
}
if !user.Grants.Has(access, auth.RO) {
return nil, fmt.Errorf("Insufficient access granted for webhook event %s", ev.String())
}
}
u, err := url.Parse(config.URL)
if err != nil {
return nil, err
} else if u.Host == "" {
return nil, fmt.Errorf("Cannot use URL without host")
} else if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("Cannot use non-HTTP or HTTPS URL")
}
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `
INSERT INTO gql_user_wh_sub (
created, events, url, query,
auth_method,
token_hash, grants, client_id, expires,
node_id,
user_id
) VALUES (
NOW() at time zone 'utc',
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
) RETURNING id, url, query, events, user_id;`,
pq.Array(events), config.URL, config.Query,
ac.AuthMethod,
ac.TokenHash, ac.Grants, ac.ClientID, ac.Expires, // OAUTH2
ac.NodeID, // INTERNAL
user.UserID)
if err := row.Scan(&sub.ID, &sub.URL,
&sub.Query, pq.Array(&sub.Events), &sub.UserID); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return &sub, nil
}
// DeleteUserWebhook is the resolver for the deleteUserWebhook field.
func (r *mutationResolver) DeleteUserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
var sub model.UserWebhookSubscription
filter, err := corewebhooks.FilterWebhooks(ctx)
if err != nil {
return nil, err
}
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := sq.Delete(`gql_user_wh_sub`).
PlaceholderFormat(sq.Dollar).
Where(sq.And{sq.Expr(`id = ?`, id), filter}).
Suffix(`RETURNING id, url, query, events, user_id`).
RunWith(tx).
QueryRowContext(ctx)
if err := row.Scan(&sub.ID, &sub.URL,
&sub.Query, pq.Array(&sub.Events), &sub.UserID); err != nil {
return err
}
return nil
}); err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("No user webhook by ID %d found for this user", id)
}
return nil, err
}
return &sub, nil
}
// DeleteUser is the resolver for the deleteUser field.
func (r *mutationResolver) DeleteUser(ctx context.Context) (int, error) {
user := auth.ForContext(ctx)
account.Delete(ctx, user.UserID, user.Username)
return user.UserID, nil
}
// Version is the resolver for the version field.
func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
conf := config.ForContext(ctx)
upstream, _ := conf.Get("objects", "s3-upstream")
accessKey, _ := conf.Get("objects", "s3-access-key")
secretKey, _ := conf.Get("objects", "s3-secret-key")
bucket, _ := conf.Get("git.sr.ht", "s3-bucket")
artifacts := upstream != "" && accessKey != "" && secretKey != "" && bucket != ""
sshUser, _ := conf.Get("git.sr.ht::dispatch", "/usr/bin/gitsrht-keys")
sshUser = strings.Split(sshUser, ":")[0]
return &model.Version{
Major: 0,
Minor: 0,
Patch: 0,
DeprecationDate: nil,
Features: &model.Features{
Artifacts: artifacts,
},
Settings: &model.Settings{
SSHUser: sshUser,
},
}, nil
}
// Me is the resolver for the me field.
func (r *queryResolver) Me(ctx context.Context) (*model.User, error) {
user := auth.ForContext(ctx)
return &model.User{
ID: user.UserID,
Created: user.Created,
Updated: user.Updated,
Username: user.Username,
Email: user.Email,
URL: user.URL,
Location: user.Location,
Bio: user.Bio,
}, nil
}
// User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context, username string) (*model.User, error) {
return loaders.ForContext(ctx).UsersByName.Load(username)
}
// Repositories is the resolver for the repositories field.
func (r *queryResolver) Repositories(ctx context.Context, cursor *coremodel.Cursor, filter *coremodel.Filter) (*model.RepositoryCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(filter)
}
var repos []*model.Repository
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
repo := (&model.Repository{}).As(`repo`)
query := database.
Select(ctx, repo).
From(`repository repo`).
Where(`repo.owner_id = ?`, auth.ForContext(ctx).UserID)
repos, cursor = repo.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.RepositoryCursor{repos, cursor}, nil
}
// UserWebhooks is the resolver for the userWebhooks field.
func (r *queryResolver) UserWebhooks(ctx context.Context, cursor *coremodel.Cursor) (*model.WebhookSubscriptionCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(nil)
}
filter, err := corewebhooks.FilterWebhooks(ctx)
if err != nil {
return nil, err
}
var subs []model.WebhookSubscription
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
sub := (&model.UserWebhookSubscription{}).As(`sub`)
query := database.
Select(ctx, sub).
From(`gql_user_wh_sub sub`).
Where(filter)
subs, cursor = sub.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.WebhookSubscriptionCursor{subs, cursor}, nil
}
// UserWebhook is the resolver for the userWebhook field.
func (r *queryResolver) UserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
var sub model.UserWebhookSubscription
filter, err := corewebhooks.FilterWebhooks(ctx)
if err != nil {
return nil, err
}
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
row := database.
Select(ctx, &sub).
From(`gql_user_wh_sub`).
Where(sq.And{sq.Expr(`id = ?`, id), filter}).
RunWith(tx).
QueryRowContext(ctx)
if err := row.Scan(database.Scan(ctx, &sub)...); err != nil {
return err
}
return nil
}); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &sub, nil
}
// Webhook is the resolver for the webhook field.
func (r *queryResolver) Webhook(ctx context.Context) (model.WebhookPayload, error) {
raw, err := corewebhooks.Payload(ctx)
if err != nil {
return nil, err
}
payload, ok := raw.(model.WebhookPayload)
if !ok {
panic("Invalid webhook payload context")
}
return payload, nil
}
// Artifacts is the resolver for the artifacts field.
func (r *referenceResolver) Artifacts(ctx context.Context, obj *model.Reference, cursor *coremodel.Cursor) (*model.ArtifactCursor, error) {
// XXX: This could utilize a loader if it ever becomes a bottleneck
if cursor == nil {
cursor = coremodel.NewCursor(nil)
}
repo := obj.Repo.Repo()
repo.Lock()
defer repo.Unlock()
ref, err := repo.Reference(obj.Ref.Name(), true)
if err != nil {
return nil, err
}
o, err := repo.Object(plumbing.TagObject, ref.Hash())
if err == plumbing.ErrObjectNotFound {
return &model.ArtifactCursor{nil, cursor}, nil
} else if err != nil {
return nil, err
}
tag, ok := o.(*object.Tag)
if !ok {
panic(fmt.Errorf("Expected artifact to be attached to tag"))
}
var arts []*model.Artifact
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
artifact := (&model.Artifact{}).As(`art`)
query := database.
Select(ctx, artifact).
From(`artifacts art`).
Where(`art.repo_id = ?`, obj.Repo.ID).
Where(`art.commit = ?`, tag.Target.String())
arts, cursor = artifact.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.ArtifactCursor{arts, cursor}, nil
}
// Owner is the resolver for the owner field.
func (r *repositoryResolver) Owner(ctx context.Context, obj *model.Repository) (model.Entity, error) {
return loaders.ForContext(ctx).UsersByID.Load(obj.OwnerID)
}
// Access is the resolver for the access field.
func (r *repositoryResolver) Access(ctx context.Context, obj *model.Repository) (model.AccessMode, error) {
currentUser := auth.ForContext(ctx)
if obj.OwnerID == currentUser.UserID {
return model.AccessModeRw, nil
}
mode := model.AccessModeRo
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
var rawAccessMode string
row := tx.QueryRowContext(ctx, `
SELECT mode
FROM access
WHERE
access.repo_id = $1 AND
access.user_id = $2;
`, obj.ID, currentUser.UserID)
if err := row.Scan(&rawAccessMode); err != nil {
if err == sql.ErrNoRows {
return nil
}
return err
}
mode = model.AccessMode(strings.ToUpper(rawAccessMode))
if !mode.IsValid() {
panic(fmt.Errorf("Invalid access mode '%s'", rawAccessMode))
}
return nil
}); err != nil {
return mode, err
}
return mode, nil
}
// Acls is the resolver for the acls field.
func (r *repositoryResolver) Acls(ctx context.Context, obj *model.Repository, cursor *coremodel.Cursor) (*model.ACLCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(nil)
}
var acls []*model.ACL
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
acl := (&model.ACL{}).As(`acl`)
query := database.
Select(ctx, acl).
From(`access acl`).
Join(`repository repo ON acl.repo_id = repo.id`).
Where(`acl.repo_id = ?`, obj.ID).
Where(`repo.owner_id = ?`, auth.ForContext(ctx).UserID)
acls, cursor = acl.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.ACLCursor{acls, cursor}, nil
}
// Objects is the resolver for the objects field.
func (r *repositoryResolver) Objects(ctx context.Context, obj *model.Repository, ids []string) ([]model.Object, error) {
var objects []model.Object
for _, id := range ids {
hash := plumbing.NewHash(id)
o, err := model.LookupObject(obj.Repo(), hash)
if err != nil {
return nil, err
}
objects = append(objects, o)
}
return objects, nil
}
// References is the resolver for the references field.
func (r *repositoryResolver) References(ctx context.Context, obj *model.Repository, cursor *coremodel.Cursor) (*model.ReferenceCursor, error) {
repo := obj.Repo()
repo.Lock()
defer repo.Unlock()
iter, err := repo.References()
if err != nil {
return nil, err
}
defer iter.Close()
if cursor == nil {
cursor = coremodel.NewCursor(nil)
}
var refs []*model.Reference
iter.ForEach(func(ref *plumbing.Reference) error {
refs = append(refs, &model.Reference{obj, ref})
return nil
})
sort.SliceStable(refs, func(i, j int) bool {
return refs[i].Name() < refs[j].Name()
})
if cursor.Next != "" {
i := sort.Search(len(refs), func(n int) bool {
return refs[n].Name() > cursor.Next
})
if i != len(refs) {
refs = refs[i+1:]
} else {
refs = nil
}
}
if len(refs) > cursor.Count {
cursor = &coremodel.Cursor{
Count: cursor.Count,
Next: refs[cursor.Count].Name(),
Search: cursor.Search,
}
refs = refs[:cursor.Count]
} else {
cursor = nil
}
return &model.ReferenceCursor{refs, cursor}, nil
}
// Log is the resolver for the log field.
func (r *repositoryResolver) Log(ctx context.Context, obj *model.Repository, cursor *coremodel.Cursor, from *string) (*model.CommitCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(nil)
if from != nil {
cursor.Next = *from
}
}
repo := obj.Repo()
opts := &git.LogOptions{
Order: git.LogOrderCommitterTime,
}
if cursor.Next != "" {
repo.Lock()
rev, err := repo.ResolveRevision(plumbing.Revision(cursor.Next))
repo.Unlock()
if err != nil {
return nil, err
}
if rev == nil {
return nil, fmt.Errorf("No such revision")
}
opts.From = *rev
}
repo.Lock()
log, err := repo.Log(opts)
repo.Unlock()
if err != nil {
return nil, err
}
var commits []*model.Commit
log.ForEach(func(c *object.Commit) error {
commits = append(commits, model.CommitFromObject(repo, c))
if len(commits) == cursor.Count+1 {
return storer.ErrStop
}
return nil
})
if len(commits) > cursor.Count {
cursor = &coremodel.Cursor{
Count: cursor.Count,
Next: commits[cursor.Count].ID,
Search: "",
}
commits = commits[:cursor.Count]
} else {
cursor = nil
}
return &model.CommitCursor{commits, cursor}, nil
}
// Path is the resolver for the path field.
func (r *repositoryResolver) Path(ctx context.Context, obj *model.Repository, revspec *string, path string) (*model.TreeEntry, error) {
rev := plumbing.Revision("HEAD")
if revspec != nil {
rev = plumbing.Revision(*revspec)
}
repo := obj.Repo()
repo.Lock()
hash, err := repo.ResolveRevision(rev)
repo.Unlock()
if err != nil {
return nil, err
}
if hash == nil {
return nil, fmt.Errorf("No such object")
}
repo.Lock()
defer repo.Unlock()
o, err := repo.Object(plumbing.CommitObject, *hash)
if err != nil {
return nil, err
}
var (
commit *object.Commit
tree *model.Tree
)
commit, _ = o.(*object.Commit)
if treeObj, err := commit.Tree(); err != nil {
panic(err)
} else {
tree = model.TreeFromObject(repo, treeObj)
}
return tree.Entry(path), nil
}
// RevparseSingle is the resolver for the revparse_single field.
func (r *repositoryResolver) RevparseSingle(ctx context.Context, obj *model.Repository, revspec string) (*model.Commit, error) {
rev := plumbing.Revision(revspec)
repo := obj.Repo()
repo.Lock()
hash, err := repo.ResolveRevision(rev)
repo.Unlock()
if err != nil {
return nil, err
}
if hash == nil {
return nil, fmt.Errorf("No such object")
}
o, err := model.LookupObject(repo, *hash)
if err != nil {
return nil, err
}
commit, _ := o.(*model.Commit)
return commit, nil
}
// Entries is the resolver for the entries field.
func (r *treeResolver) Entries(ctx context.Context, obj *model.Tree, cursor *coremodel.Cursor) (*model.TreeEntryCursor, error) {
if cursor == nil {
// TODO: Filter?
cursor = coremodel.NewCursor(nil)
}
entries := obj.GetEntries()
if cursor.Next != "" {
i := sort.Search(len(entries), func(n int) bool {
return entries[n].Name > cursor.Next
})
if i != len(entries) {
entries = entries[i+1:]
} else {
entries = nil
}
}
if len(entries) > cursor.Count {
cursor = &coremodel.Cursor{
Count: cursor.Count,
Next: entries[cursor.Count].Name,
Search: cursor.Search,
}
entries = entries[:cursor.Count]
} else {
cursor = nil
}
return &model.TreeEntryCursor{entries, cursor}, nil
}
// Repository is the resolver for the repository field.
func (r *userResolver) Repository(ctx context.Context, obj *model.User, name string) (*model.Repository, error) {
return loaders.ForContext(ctx).RepositoriesByOwnerIDRepoName.Load(loaders.OwnerIDRepoName{obj.ID, name})
}
// Repositories is the resolver for the repositories field.
func (r *userResolver) Repositories(ctx context.Context, obj *model.User, cursor *coremodel.Cursor, filter *coremodel.Filter) (*model.RepositoryCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(filter)
}
var repos []*model.Repository
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
repo := (&model.Repository{}).As(`repo`)
query := database.
Select(ctx, repo).
From(`repository repo`).
LeftJoin(`access ON repo.id = access.repo_id`).
Where(sq.And{
sq.Or{
sq.Expr(`? IN (access.user_id, repo.owner_id)`,
auth.ForContext(ctx).UserID),
sq.Expr(`repo.visibility = 'PUBLIC'`),
},
sq.Expr(`repo.owner_id = ?`, obj.ID),
})
repos, cursor = repo.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.RepositoryCursor{repos, cursor}, nil
}
// Client is the resolver for the client field.
func (r *userWebhookSubscriptionResolver) Client(ctx context.Context, obj *model.UserWebhookSubscription) (*model.OAuthClient, error) {
if obj.ClientID == nil {
return nil, nil
}
return &model.OAuthClient{
UUID: *obj.ClientID,
}, nil
}
// Deliveries is the resolver for the deliveries field.
func (r *userWebhookSubscriptionResolver) Deliveries(ctx context.Context, obj *model.UserWebhookSubscription, cursor *coremodel.Cursor) (*model.WebhookDeliveryCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(nil)
}
var deliveries []*model.WebhookDelivery
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
d := (&model.WebhookDelivery{}).
WithName(`user`).
As(`delivery`)
query := database.
Select(ctx, d).
From(`gql_user_wh_delivery delivery`).
Where(`delivery.subscription_id = ?`, obj.ID)
deliveries, cursor = d.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.WebhookDeliveryCursor{deliveries, cursor}, nil
}
// Sample is the resolver for the sample field.
func (r *userWebhookSubscriptionResolver) Sample(ctx context.Context, obj *model.UserWebhookSubscription, event *model.WebhookEvent) (string, error) {
// TODO
panic(fmt.Errorf("not implemented"))
}
// Subscription is the resolver for the subscription field.
func (r *webhookDeliveryResolver) Subscription(ctx context.Context, obj *model.WebhookDelivery) (model.WebhookSubscription, error) {
if obj.Name == "" {
panic("WebhookDelivery without name")
}
// XXX: This could use a loader but it's unlikely to be a bottleneck
var sub model.WebhookSubscription
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
// XXX: This needs some work to generalize to other kinds of webhooks
subscription := (&model.UserWebhookSubscription{}).As(`sub`)
// Note: No filter needed because, if we have access to the delivery,
// we also have access to the subscription.
row := database.
Select(ctx, subscription).
From(`gql_user_wh_sub sub`).
Where(`sub.id = ?`, obj.SubscriptionID).
RunWith(tx).
QueryRowContext(ctx)
if err := row.Scan(database.Scan(ctx, subscription)...); err != nil {
return err
}
sub = subscription
return nil
}); err != nil {
return nil, err
}
return sub, nil
}
// ACL returns api.ACLResolver implementation.
func (r *Resolver) ACL() api.ACLResolver { return &aCLResolver{r} }
// Artifact returns api.ArtifactResolver implementation.
func (r *Resolver) Artifact() api.ArtifactResolver { return &artifactResolver{r} }
// Commit returns api.CommitResolver implementation.
func (r *Resolver) Commit() api.CommitResolver { return &commitResolver{r} }
// Mutation returns api.MutationResolver implementation.
func (r *Resolver) Mutation() api.MutationResolver { return &mutationResolver{r} }
// Query returns api.QueryResolver implementation.
func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} }
// Reference returns api.ReferenceResolver implementation.
func (r *Resolver) Reference() api.ReferenceResolver { return &referenceResolver{r} }
// Repository returns api.RepositoryResolver implementation.
func (r *Resolver) Repository() api.RepositoryResolver { return &repositoryResolver{r} }
// Tree returns api.TreeResolver implementation.
func (r *Resolver) Tree() api.TreeResolver { return &treeResolver{r} }
// User returns api.UserResolver implementation.
func (r *Resolver) User() api.UserResolver { return &userResolver{r} }
// UserWebhookSubscription returns api.UserWebhookSubscriptionResolver implementation.
func (r *Resolver) UserWebhookSubscription() api.UserWebhookSubscriptionResolver {
return &userWebhookSubscriptionResolver{r}
}
// WebhookDelivery returns api.WebhookDeliveryResolver implementation.
func (r *Resolver) WebhookDelivery() api.WebhookDeliveryResolver { return &webhookDeliveryResolver{r} }
type aCLResolver struct{ *Resolver }
type artifactResolver struct{ *Resolver }
type commitResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type referenceResolver struct{ *Resolver }
type repositoryResolver struct{ *Resolver }
type treeResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
type userWebhookSubscriptionResolver struct{ *Resolver }
type webhookDeliveryResolver struct{ *Resolver }