Detailed changes
@@ -174,7 +174,7 @@ You can launch a rich Web UI with `git bug webui`.
This web UI is entirely packed inside the same go binary and serve static content through a localhost http server.
-The web UI interact with the backend through a GraphQL API. The schema is available [here](graphql/).
+The web UI interact with the backend through a GraphQL API. The schema is available [here](api/graphql/schema).
## Bridges
@@ -0,0 +1,28 @@
+// Package auth contains helpers for managing identities within the GraphQL API.
+package auth
+
+import (
+ "context"
+
+ "github.com/MichaelMure/git-bug/cache"
+ "github.com/MichaelMure/git-bug/entity"
+)
+
+// identityCtxKey is a unique context key, accessible only in this package.
+var identityCtxKey = &struct{}{}
+
+// CtxWithUser attaches an Identity to a context.
+func CtxWithUser(ctx context.Context, userId entity.Id) context.Context {
+ return context.WithValue(ctx, identityCtxKey, userId)
+}
+
+// UserFromCtx retrieves an IdentityCache from the context.
+// If there is no identity in the context, ErrNotAuthenticated is returned.
+// If an error occurs while resolving the identity (e.g. I/O error), then it will be returned.
+func UserFromCtx(ctx context.Context, r *cache.RepoCache) (*cache.IdentityCache, error) {
+ id, ok := ctx.Value(identityCtxKey).(entity.Id)
+ if !ok {
+ return nil, ErrNotAuthenticated
+ }
+ return r.ResolveIdentity(id)
+}
@@ -0,0 +1,6 @@
+package auth
+
+import "errors"
+
+// ErrNotAuthenticated is returned to the client if the user requests an action requiring authentication, and they are not authenticated.
+var ErrNotAuthenticated = errors.New("not authenticated or read-only")
@@ -0,0 +1,16 @@
+package auth
+
+import (
+ "net/http"
+
+ "github.com/MichaelMure/git-bug/entity"
+)
+
+func Middleware(fixedUserId entity.Id) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := CtxWithUser(r.Context(), fixedUserId)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
@@ -5,7 +5,7 @@ import (
"github.com/cheekybits/genny/generic"
- "github.com/MichaelMure/git-bug/graphql/models"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
)
// Name define the name of the connection
@@ -7,8 +7,8 @@ package connections
import (
"fmt"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/bug"
- "github.com/MichaelMure/git-bug/graphql/models"
)
// BugCommentEdgeMaker define a function that take a bug.Comment and an offset and
@@ -7,7 +7,7 @@ package connections
import (
"fmt"
- "github.com/MichaelMure/git-bug/graphql/models"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
)
// ModelsIdentityWrapperEdgeMaker define a function that take a models.IdentityWrapper and an offset and
@@ -7,8 +7,8 @@ package connections
import (
"fmt"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/bug"
- "github.com/MichaelMure/git-bug/graphql/models"
)
// BugLabelEdgeMaker define a function that take a bug.Label and an offset and
@@ -7,8 +7,8 @@ package connections
import (
"fmt"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/entity"
- "github.com/MichaelMure/git-bug/graphql/models"
)
// EntityIdEdgeMaker define a function that take a entity.Id and an offset and
@@ -7,8 +7,8 @@ package connections
import (
"fmt"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/entity"
- "github.com/MichaelMure/git-bug/graphql/models"
)
// EntityIdEdgeMaker define a function that take a entity.Id and an offset and
@@ -7,8 +7,8 @@ package connections
import (
"fmt"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/bug"
- "github.com/MichaelMure/git-bug/graphql/models"
)
// BugOperationEdgeMaker define a function that take a bug.Operation and an offset and
@@ -7,8 +7,8 @@ package connections
import (
"fmt"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/bug"
- "github.com/MichaelMure/git-bug/graphql/models"
)
// BugTimelineItemEdgeMaker define a function that take a bug.TimelineItem and an offset and
@@ -15,11 +15,12 @@ import (
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/introspection"
- "github.com/MichaelMure/git-bug/bug"
- "github.com/MichaelMure/git-bug/graphql/models"
- "github.com/MichaelMure/git-bug/util/git"
"github.com/vektah/gqlparser"
"github.com/vektah/gqlparser/ast"
+
+ "github.com/MichaelMure/git-bug/api/graphql/models"
+ "github.com/MichaelMure/git-bug/bug"
+ "github.com/MichaelMure/git-bug/util/git"
)
// region ************************** generated!.gotpl **************************
@@ -4,8 +4,11 @@ import (
"testing"
"github.com/99designs/gqlgen/client"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
- "github.com/MichaelMure/git-bug/graphql/models"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
+ "github.com/MichaelMure/git-bug/cache"
"github.com/MichaelMure/git-bug/misc/random_bugs"
"github.com/MichaelMure/git-bug/repository"
)
@@ -16,10 +19,11 @@ func TestQueries(t *testing.T) {
random_bugs.FillRepoWithSeed(repo, 10, 42)
- handler, err := NewHandler(repo)
- if err != nil {
- t.Fatal(err)
- }
+ mrc := cache.NewMultiRepoCache()
+ _, err := mrc.RegisterDefaultRepository(repo)
+ require.NoError(t, err)
+
+ handler := NewHandler(mrc)
c := client.New(handler)
@@ -211,5 +215,6 @@ func TestQueries(t *testing.T) {
}
}
- c.MustPost(query, &resp)
+ err = c.Post(query, &resp)
+ assert.NoError(t, err)
}
@@ -0,0 +1,32 @@
+//go:generate go run gen_graphql.go
+
+// Package graphql contains the root GraphQL http handler
+package graphql
+
+import (
+ "io"
+ "net/http"
+
+ "github.com/99designs/gqlgen/graphql/handler"
+
+ "github.com/MichaelMure/git-bug/api/graphql/graph"
+ "github.com/MichaelMure/git-bug/api/graphql/resolvers"
+ "github.com/MichaelMure/git-bug/cache"
+)
+
+// Handler is the root GraphQL http handler
+type Handler struct {
+ http.Handler
+ io.Closer
+}
+
+func NewHandler(mrc *cache.MultiRepoCache) Handler {
+ rootResolver := resolvers.NewRootResolver(mrc)
+ config := graph.Config{Resolvers: rootResolver}
+ h := handler.NewDefaultServer(graph.NewExecutableSchema(config))
+
+ return Handler{
+ Handler: h,
+ Closer: rootResolver,
+ }
+}
@@ -3,10 +3,10 @@ package resolvers
import (
"context"
+ "github.com/MichaelMure/git-bug/api/graphql/connections"
+ "github.com/MichaelMure/git-bug/api/graphql/graph"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/bug"
- "github.com/MichaelMure/git-bug/graphql/connections"
- "github.com/MichaelMure/git-bug/graphql/graph"
- "github.com/MichaelMure/git-bug/graphql/models"
)
var _ graph.BugResolver = &bugResolver{}
@@ -4,7 +4,7 @@ import (
"context"
"image/color"
- "github.com/MichaelMure/git-bug/graphql/graph"
+ "github.com/MichaelMure/git-bug/api/graphql/graph"
)
var _ graph.ColorResolver = &colorResolver{}
@@ -3,9 +3,9 @@ package resolvers
import (
"context"
+ "github.com/MichaelMure/git-bug/api/graphql/graph"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/bug"
- "github.com/MichaelMure/git-bug/graphql/graph"
- "github.com/MichaelMure/git-bug/graphql/models"
)
var _ graph.CommentResolver = &commentResolver{}
@@ -3,8 +3,8 @@ package resolvers
import (
"context"
- "github.com/MichaelMure/git-bug/graphql/graph"
- "github.com/MichaelMure/git-bug/graphql/models"
+ "github.com/MichaelMure/git-bug/api/graphql/graph"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
)
var _ graph.IdentityResolver = &identityResolver{}
@@ -5,9 +5,9 @@ import (
"fmt"
"image/color"
+ "github.com/MichaelMure/git-bug/api/graphql/graph"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/bug"
- "github.com/MichaelMure/git-bug/graphql/graph"
- "github.com/MichaelMure/git-bug/graphql/models"
)
var _ graph.LabelResolver = &labelResolver{}
@@ -2,11 +2,13 @@ package resolvers
import (
"context"
+ "time"
+ "github.com/MichaelMure/git-bug/api/auth"
+ "github.com/MichaelMure/git-bug/api/graphql/graph"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/bug"
"github.com/MichaelMure/git-bug/cache"
- "github.com/MichaelMure/git-bug/graphql/graph"
- "github.com/MichaelMure/git-bug/graphql/models"
)
var _ graph.MutationResolver = &mutationResolver{}
@@ -23,22 +25,31 @@ func (r mutationResolver) getRepo(ref *string) (*cache.RepoCache, error) {
return r.cache.DefaultRepo()
}
-func (r mutationResolver) getBug(repoRef *string, bugPrefix string) (*cache.BugCache, error) {
+func (r mutationResolver) getBug(repoRef *string, bugPrefix string) (*cache.RepoCache, *cache.BugCache, error) {
repo, err := r.getRepo(repoRef)
if err != nil {
- return nil, err
+ return nil, nil, err
}
- return repo.ResolveBugPrefix(bugPrefix)
+ b, err := repo.ResolveBugPrefix(bugPrefix)
+ if err != nil {
+ return nil, nil, err
+ }
+ return repo, b, nil
}
-func (r mutationResolver) NewBug(_ context.Context, input models.NewBugInput) (*models.NewBugPayload, error) {
+func (r mutationResolver) NewBug(ctx context.Context, input models.NewBugInput) (*models.NewBugPayload, error) {
repo, err := r.getRepo(input.RepoRef)
if err != nil {
return nil, err
}
- b, op, err := repo.NewBugWithFiles(input.Title, input.Message, input.Files)
+ author, err := auth.UserFromCtx(ctx, repo)
+ if err != nil {
+ return nil, err
+ }
+
+ b, op, err := repo.NewBugRaw(author, time.Now().Unix(), input.Title, input.Message, input.Files, nil)
if err != nil {
return nil, err
}
@@ -50,13 +61,18 @@ func (r mutationResolver) NewBug(_ context.Context, input models.NewBugInput) (*
}, nil
}
-func (r mutationResolver) AddComment(_ context.Context, input models.AddCommentInput) (*models.AddCommentPayload, error) {
- b, err := r.getBug(input.RepoRef, input.Prefix)
+func (r mutationResolver) AddComment(ctx context.Context, input models.AddCommentInput) (*models.AddCommentPayload, error) {
+ repo, b, err := r.getBug(input.RepoRef, input.Prefix)
if err != nil {
return nil, err
}
- op, err := b.AddCommentWithFiles(input.Message, input.Files)
+ author, err := auth.UserFromCtx(ctx, repo)
+ if err != nil {
+ return nil, err
+ }
+
+ op, err := b.AddCommentRaw(author, time.Now().Unix(), input.Message, input.Files, nil)
if err != nil {
return nil, err
}
@@ -73,13 +89,18 @@ func (r mutationResolver) AddComment(_ context.Context, input models.AddCommentI
}, nil
}
-func (r mutationResolver) ChangeLabels(_ context.Context, input *models.ChangeLabelInput) (*models.ChangeLabelPayload, error) {
- b, err := r.getBug(input.RepoRef, input.Prefix)
+func (r mutationResolver) ChangeLabels(ctx context.Context, input *models.ChangeLabelInput) (*models.ChangeLabelPayload, error) {
+ repo, b, err := r.getBug(input.RepoRef, input.Prefix)
if err != nil {
return nil, err
}
- results, op, err := b.ChangeLabels(input.Added, input.Removed)
+ author, err := auth.UserFromCtx(ctx, repo)
+ if err != nil {
+ return nil, err
+ }
+
+ results, op, err := b.ChangeLabelsRaw(author, time.Now().Unix(), input.Added, input.Removed, nil)
if err != nil {
return nil, err
}
@@ -102,13 +123,18 @@ func (r mutationResolver) ChangeLabels(_ context.Context, input *models.ChangeLa
}, nil
}
-func (r mutationResolver) OpenBug(_ context.Context, input models.OpenBugInput) (*models.OpenBugPayload, error) {
- b, err := r.getBug(input.RepoRef, input.Prefix)
+func (r mutationResolver) OpenBug(ctx context.Context, input models.OpenBugInput) (*models.OpenBugPayload, error) {
+ repo, b, err := r.getBug(input.RepoRef, input.Prefix)
if err != nil {
return nil, err
}
- op, err := b.Open()
+ author, err := auth.UserFromCtx(ctx, repo)
+ if err != nil {
+ return nil, err
+ }
+
+ op, err := b.OpenRaw(author, time.Now().Unix(), nil)
if err != nil {
return nil, err
}
@@ -125,13 +151,18 @@ func (r mutationResolver) OpenBug(_ context.Context, input models.OpenBugInput)
}, nil
}
-func (r mutationResolver) CloseBug(_ context.Context, input models.CloseBugInput) (*models.CloseBugPayload, error) {
- b, err := r.getBug(input.RepoRef, input.Prefix)
+func (r mutationResolver) CloseBug(ctx context.Context, input models.CloseBugInput) (*models.CloseBugPayload, error) {
+ repo, b, err := r.getBug(input.RepoRef, input.Prefix)
if err != nil {
return nil, err
}
- op, err := b.Close()
+ author, err := auth.UserFromCtx(ctx, repo)
+ if err != nil {
+ return nil, err
+ }
+
+ op, err := b.CloseRaw(author, time.Now().Unix(), nil)
if err != nil {
return nil, err
}
@@ -148,13 +179,18 @@ func (r mutationResolver) CloseBug(_ context.Context, input models.CloseBugInput
}, nil
}
-func (r mutationResolver) SetTitle(_ context.Context, input models.SetTitleInput) (*models.SetTitlePayload, error) {
- b, err := r.getBug(input.RepoRef, input.Prefix)
+func (r mutationResolver) SetTitle(ctx context.Context, input models.SetTitleInput) (*models.SetTitlePayload, error) {
+ repo, b, err := r.getBug(input.RepoRef, input.Prefix)
+ if err != nil {
+ return nil, err
+ }
+
+ author, err := auth.UserFromCtx(ctx, repo)
if err != nil {
return nil, err
}
- op, err := b.SetTitle(input.Title)
+ op, err := b.SetTitleRaw(author, time.Now().Unix(), input.Title, nil)
if err != nil {
return nil, err
}
@@ -5,9 +5,9 @@ import (
"fmt"
"time"
+ "github.com/MichaelMure/git-bug/api/graphql/graph"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/bug"
- "github.com/MichaelMure/git-bug/graphql/graph"
- "github.com/MichaelMure/git-bug/graphql/models"
)
var _ graph.CreateOperationResolver = createOperationResolver{}
@@ -3,9 +3,9 @@ package resolvers
import (
"context"
+ "github.com/MichaelMure/git-bug/api/graphql/graph"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/cache"
- "github.com/MichaelMure/git-bug/graphql/graph"
- "github.com/MichaelMure/git-bug/graphql/models"
)
var _ graph.QueryResolver = &rootQueryResolver{}
@@ -3,11 +3,12 @@ package resolvers
import (
"context"
+ "github.com/MichaelMure/git-bug/api/auth"
+ "github.com/MichaelMure/git-bug/api/graphql/connections"
+ "github.com/MichaelMure/git-bug/api/graphql/graph"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/bug"
"github.com/MichaelMure/git-bug/entity"
- "github.com/MichaelMure/git-bug/graphql/connections"
- "github.com/MichaelMure/git-bug/graphql/graph"
- "github.com/MichaelMure/git-bug/graphql/models"
"github.com/MichaelMure/git-bug/query"
)
@@ -149,13 +150,14 @@ func (repoResolver) Identity(_ context.Context, obj *models.Repository, prefix s
return models.NewLazyIdentity(obj.Repo, excerpt), nil
}
-func (repoResolver) UserIdentity(_ context.Context, obj *models.Repository) (models.IdentityWrapper, error) {
- excerpt, err := obj.Repo.GetUserIdentityExcerpt()
- if err != nil {
+func (repoResolver) UserIdentity(ctx context.Context, obj *models.Repository) (models.IdentityWrapper, error) {
+ id, err := auth.UserFromCtx(ctx, obj.Repo)
+ if err == auth.ErrNotAuthenticated {
+ return nil, nil
+ } else if err != nil {
return nil, err
}
-
- return models.NewLazyIdentity(obj.Repo, excerpt), nil
+ return models.NewLoadedIdentity(id.Identity), nil
}
func (repoResolver) ValidLabels(_ context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.LabelConnection, error) {
@@ -2,31 +2,31 @@
package resolvers
import (
+ "github.com/MichaelMure/git-bug/api/graphql/graph"
"github.com/MichaelMure/git-bug/cache"
- "github.com/MichaelMure/git-bug/graphql/graph"
)
var _ graph.ResolverRoot = &RootResolver{}
type RootResolver struct {
- cache.MultiRepoCache
+ *cache.MultiRepoCache
}
-func NewRootResolver() *RootResolver {
+func NewRootResolver(mrc *cache.MultiRepoCache) *RootResolver {
return &RootResolver{
- MultiRepoCache: cache.NewMultiRepoCache(),
+ MultiRepoCache: mrc,
}
}
func (r RootResolver) Query() graph.QueryResolver {
return &rootQueryResolver{
- cache: &r.MultiRepoCache,
+ cache: r.MultiRepoCache,
}
}
func (r RootResolver) Mutation() graph.MutationResolver {
return &mutationResolver{
- cache: &r.MultiRepoCache,
+ cache: r.MultiRepoCache,
}
}
@@ -4,9 +4,9 @@ import (
"context"
"time"
+ "github.com/MichaelMure/git-bug/api/graphql/graph"
+ "github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/bug"
- "github.com/MichaelMure/git-bug/graphql/graph"
- "github.com/MichaelMure/git-bug/graphql/models"
)
var _ graph.CommentHistoryStepResolver = commentHistoryStepResolver{}
@@ -0,0 +1,61 @@
+package http
+
+import (
+ "bytes"
+ "net/http"
+ "time"
+
+ "github.com/gorilla/mux"
+
+ "github.com/MichaelMure/git-bug/cache"
+ "github.com/MichaelMure/git-bug/util/git"
+)
+
+// implement a http.Handler that will read and server git blob.
+//
+// Expected gorilla/mux parameters:
+// - "repo" : the ref of the repo or "" for the default one
+// - "hash" : the git hash of the file to retrieve
+type gitFileHandler struct {
+ mrc *cache.MultiRepoCache
+}
+
+func NewGitFileHandler(mrc *cache.MultiRepoCache) http.Handler {
+ return &gitFileHandler{mrc: mrc}
+}
+
+func (gfh *gitFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+ var repo *cache.RepoCache
+ var err error
+
+ repoVar := mux.Vars(r)["repo"]
+ switch repoVar {
+ case "":
+ repo, err = gfh.mrc.DefaultRepo()
+ default:
+ repo, err = gfh.mrc.ResolveRepo(repoVar)
+ }
+
+ if err != nil {
+ http.Error(rw, "invalid repo reference", http.StatusBadRequest)
+ return
+ }
+
+ hash := git.Hash(mux.Vars(r)["hash"])
+ if !hash.IsValid() {
+ http.Error(rw, "invalid git hash", http.StatusBadRequest)
+ return
+ }
+
+ // TODO: this mean that the whole file will he buffered in memory
+ // This can be a problem for big files. There might be a way around
+ // that by implementing a io.ReadSeeker that would read and discard
+ // data when a seek is called.
+ data, err := repo.ReadData(hash)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ http.ServeContent(rw, r, "", time.Now(), bytes.NewReader(data))
+}
@@ -0,0 +1,91 @@
+package http
+
+import (
+ "bytes"
+ "image"
+ "image/png"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gorilla/mux"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/api/auth"
+ "github.com/MichaelMure/git-bug/cache"
+ "github.com/MichaelMure/git-bug/repository"
+)
+
+func TestGitFileHandlers(t *testing.T) {
+ repo := repository.CreateTestRepo(false)
+ defer repository.CleanupTestRepos(repo)
+
+ mrc := cache.NewMultiRepoCache()
+ repoCache, err := mrc.RegisterDefaultRepository(repo)
+ require.NoError(t, err)
+
+ author, err := repoCache.NewIdentity("test identity", "test@test.org")
+ require.NoError(t, err)
+
+ err = repoCache.SetUserIdentity(author)
+ require.NoError(t, err)
+
+ // UPLOAD
+
+ uploadHandler := NewGitUploadFileHandler(mrc)
+
+ img := image.NewNRGBA(image.Rect(0, 0, 50, 50))
+ data := &bytes.Buffer{}
+ err = png.Encode(data, img)
+ require.NoError(t, err)
+
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("uploadfile", "noname")
+ assert.NoError(t, err)
+
+ _, err = part.Write(data.Bytes())
+ assert.NoError(t, err)
+
+ err = writer.Close()
+ assert.NoError(t, err)
+
+ w := httptest.NewRecorder()
+ r, _ := http.NewRequest("GET", "/", body)
+ r.Header.Add("Content-Type", writer.FormDataContentType())
+
+ // Simulate auth
+ r = r.WithContext(auth.CtxWithUser(r.Context(), author.Id()))
+
+ // Handler's params
+ r = mux.SetURLVars(r, map[string]string{
+ "repo": "",
+ })
+
+ uploadHandler.ServeHTTP(w, r)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, `{"hash":"3426a1488292d8f3f3c59ca679681336542b986f"}`, w.Body.String())
+ // DOWNLOAD
+
+ downloadHandler := NewGitFileHandler(mrc)
+
+ w = httptest.NewRecorder()
+ r, _ = http.NewRequest("GET", "/", nil)
+
+ // Simulate auth
+ r = r.WithContext(auth.CtxWithUser(r.Context(), author.Id()))
+
+ // Handler's params
+ r = mux.SetURLVars(r, map[string]string{
+ "repo": "",
+ "hash": "3426a1488292d8f3f3c59ca679681336542b986f",
+ })
+
+ downloadHandler.ServeHTTP(w, r)
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ assert.Equal(t, data.Bytes(), w.Body.Bytes())
+}
@@ -0,0 +1,104 @@
+package http
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+
+ "github.com/gorilla/mux"
+
+ "github.com/MichaelMure/git-bug/api/auth"
+ "github.com/MichaelMure/git-bug/cache"
+)
+
+// implement a http.Handler that will accept and store content into git blob.
+//
+// Expected gorilla/mux parameters:
+// - "repo" : the ref of the repo or "" for the default one
+type gitUploadFileHandler struct {
+ mrc *cache.MultiRepoCache
+}
+
+func NewGitUploadFileHandler(mrc *cache.MultiRepoCache) http.Handler {
+ return &gitUploadFileHandler{mrc: mrc}
+}
+
+func (gufh *gitUploadFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+ var repo *cache.RepoCache
+ var err error
+
+ repoVar := mux.Vars(r)["repo"]
+ switch repoVar {
+ case "":
+ repo, err = gufh.mrc.DefaultRepo()
+ default:
+ repo, err = gufh.mrc.ResolveRepo(repoVar)
+ }
+
+ if err != nil {
+ http.Error(rw, "invalid repo reference", http.StatusBadRequest)
+ return
+ }
+
+ _, err = auth.UserFromCtx(r.Context(), repo)
+ if err == auth.ErrNotAuthenticated {
+ http.Error(rw, "read-only mode or not logged in", http.StatusForbidden)
+ return
+ } else if err != nil {
+ http.Error(rw, fmt.Sprintf("loading identity: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // 100MB (github limit)
+ var maxUploadSize int64 = 100 * 1000 * 1000
+ r.Body = http.MaxBytesReader(rw, r.Body, maxUploadSize)
+ if err := r.ParseMultipartForm(maxUploadSize); err != nil {
+ http.Error(rw, "file too big (100MB max)", http.StatusBadRequest)
+ return
+ }
+
+ file, _, err := r.FormFile("uploadfile")
+ if err != nil {
+ http.Error(rw, "invalid file", http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+ fileBytes, err := ioutil.ReadAll(file)
+ if err != nil {
+ http.Error(rw, "invalid file", http.StatusBadRequest)
+ return
+ }
+
+ filetype := http.DetectContentType(fileBytes)
+ if filetype != "image/jpeg" && filetype != "image/jpg" &&
+ filetype != "image/gif" && filetype != "image/png" {
+ http.Error(rw, "invalid file type", http.StatusBadRequest)
+ return
+ }
+
+ hash, err := repo.StoreData(fileBytes)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ type response struct {
+ Hash string `json:"hash"`
+ }
+
+ resp := response{Hash: string(hash)}
+
+ js, err := json.Marshal(resp)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ rw.Header().Set("Content-Type", "application/json")
+ _, err = rw.Write(js)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
@@ -13,32 +13,32 @@ type MultiRepoCache struct {
repos map[string]*RepoCache
}
-func NewMultiRepoCache() MultiRepoCache {
- return MultiRepoCache{
+func NewMultiRepoCache() *MultiRepoCache {
+ return &MultiRepoCache{
repos: make(map[string]*RepoCache),
}
}
// RegisterRepository register a named repository. Use this for multi-repo setup
-func (c *MultiRepoCache) RegisterRepository(ref string, repo repository.ClockedRepo) error {
+func (c *MultiRepoCache) RegisterRepository(ref string, repo repository.ClockedRepo) (*RepoCache, error) {
r, err := NewRepoCache(repo)
if err != nil {
- return err
+ return nil, err
}
c.repos[ref] = r
- return nil
+ return r, nil
}
// RegisterDefaultRepository register a unnamed repository. Use this for mono-repo setup
-func (c *MultiRepoCache) RegisterDefaultRepository(repo repository.ClockedRepo) error {
+func (c *MultiRepoCache) RegisterDefaultRepository(repo repository.ClockedRepo) (*RepoCache, error) {
r, err := NewRepoCache(repo)
if err != nil {
- return err
+ return nil, err
}
c.repos[""] = r
- return nil
+ return r, nil
}
// DefaultRepo retrieve the default repository
@@ -142,6 +142,16 @@ func (c *RepoCache) GetUserEmail() (string, error) {
return c.repo.GetUserEmail()
}
+// ReadData will attempt to read arbitrary data from the given hash
+func (c *RepoCache) ReadData(hash git.Hash) ([]byte, error) {
+ return c.repo.ReadData(hash)
+}
+
+// StoreData will store arbitrary data and return the corresponding hash
+func (c *RepoCache) StoreData(data []byte) (git.Hash, error) {
+ return c.repo.StoreData(data)
+}
+
func (c *RepoCache) lock() error {
lockPath := repoLockFilePath(c.repo)
@@ -1,11 +1,8 @@
package commands
import (
- "bytes"
"context"
- "encoding/json"
"fmt"
- "io/ioutil"
"log"
"net/http"
"os"
@@ -18,16 +15,20 @@ import (
"github.com/skratchdot/open-golang/open"
"github.com/spf13/cobra"
- "github.com/MichaelMure/git-bug/graphql"
+ "github.com/MichaelMure/git-bug/api/auth"
+ "github.com/MichaelMure/git-bug/api/graphql"
+ httpapi "github.com/MichaelMure/git-bug/api/http"
+ "github.com/MichaelMure/git-bug/cache"
+ "github.com/MichaelMure/git-bug/identity"
"github.com/MichaelMure/git-bug/repository"
- "github.com/MichaelMure/git-bug/util/git"
"github.com/MichaelMure/git-bug/webui"
)
var (
- webUIPort int
- webUIOpen bool
- webUINoOpen bool
+ webUIPort int
+ webUIOpen bool
+ webUINoOpen bool
+ webUIReadOnly bool
)
const webUIOpenConfigKey = "git-bug.webui.open"
@@ -46,22 +47,31 @@ func runWebUI(cmd *cobra.Command, args []string) error {
router := mux.NewRouter()
- graphqlHandler, err := graphql.NewHandler(repo)
+ // If the webUI is not read-only, use an authentication middleware with a
+ // fixed identity: the default user of the repo
+ // TODO: support dynamic authentication with OAuth
+ if !webUIReadOnly {
+ author, err := identity.GetUserIdentity(repo)
+ if err != nil {
+ return err
+ }
+ router.Use(auth.Middleware(author.Id()))
+ }
+
+ mrc := cache.NewMultiRepoCache()
+ _, err := mrc.RegisterDefaultRepository(repo)
if err != nil {
return err
}
- assetsHandler := &fileSystemWithDefault{
- FileSystem: webui.WebUIAssets,
- defaultFile: "index.html",
- }
+ graphqlHandler := graphql.NewHandler(mrc)
// Routes
router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql"))
router.Path("/graphql").Handler(graphqlHandler)
- router.Path("/gitfile/{hash}").Handler(newGitFileHandler(repo))
- router.Path("/upload").Methods("POST").Handler(newGitUploadFileHandler(repo))
- router.PathPrefix("/").Handler(http.FileServer(assetsHandler))
+ router.Path("/gitfile/{repo}/{hash}").Handler(httpapi.NewGitFileHandler(mrc))
+ router.Path("/upload/{repo}").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
+ router.PathPrefix("/").Handler(webui.NewHandler())
srv := &http.Server{
Addr: addr,
@@ -128,119 +138,6 @@ func runWebUI(cmd *cobra.Command, args []string) error {
return nil
}
-// implement a http.FileSystem that will serve a default file when the looked up
-// file doesn't exist. Useful for Single-Page App that implement routing client
-// side, where the server has to return the root index.html file for every route.
-type fileSystemWithDefault struct {
- http.FileSystem
- defaultFile string
-}
-
-func (fswd *fileSystemWithDefault) Open(name string) (http.File, error) {
- f, err := fswd.FileSystem.Open(name)
- if os.IsNotExist(err) {
- return fswd.FileSystem.Open(fswd.defaultFile)
- }
- return f, err
-}
-
-// implement a http.Handler that will read and server git blob.
-type gitFileHandler struct {
- repo repository.Repo
-}
-
-func newGitFileHandler(repo repository.Repo) http.Handler {
- return &gitFileHandler{
- repo: repo,
- }
-}
-
-func (gfh *gitFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
- hash := git.Hash(mux.Vars(r)["hash"])
-
- if !hash.IsValid() {
- http.Error(rw, "invalid git hash", http.StatusBadRequest)
- return
- }
-
- // TODO: this mean that the whole file will he buffered in memory
- // This can be a problem for big files. There might be a way around
- // that by implementing a io.ReadSeeker that would read and discard
- // data when a seek is called.
- data, err := gfh.repo.ReadData(git.Hash(hash))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- http.ServeContent(rw, r, "", time.Now(), bytes.NewReader(data))
-}
-
-// implement a http.Handler that will accept and store content into git blob.
-type gitUploadFileHandler struct {
- repo repository.Repo
-}
-
-func newGitUploadFileHandler(repo repository.Repo) http.Handler {
- return &gitUploadFileHandler{
- repo: repo,
- }
-}
-
-func (gufh *gitUploadFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
- // 100MB (github limit)
- var maxUploadSize int64 = 100 * 1000 * 1000
- r.Body = http.MaxBytesReader(rw, r.Body, maxUploadSize)
- if err := r.ParseMultipartForm(maxUploadSize); err != nil {
- http.Error(rw, "file too big (100MB max)", http.StatusBadRequest)
- return
- }
-
- file, _, err := r.FormFile("uploadfile")
- if err != nil {
- http.Error(rw, "invalid file", http.StatusBadRequest)
- return
- }
- defer file.Close()
- fileBytes, err := ioutil.ReadAll(file)
- if err != nil {
- http.Error(rw, "invalid file", http.StatusBadRequest)
- return
- }
-
- filetype := http.DetectContentType(fileBytes)
- if filetype != "image/jpeg" && filetype != "image/jpg" &&
- filetype != "image/gif" && filetype != "image/png" {
- http.Error(rw, "invalid file type", http.StatusBadRequest)
- return
- }
-
- hash, err := gufh.repo.StoreData(fileBytes)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- type response struct {
- Hash string `json:"hash"`
- }
-
- resp := response{Hash: string(hash)}
-
- js, err := json.Marshal(resp)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- rw.Header().Set("Content-Type", "application/json")
- _, err = rw.Write(js)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-}
-
var webUICmd = &cobra.Command{
Use: "webui",
Short: "Launch the web UI.",
@@ -249,7 +146,7 @@ var webUICmd = &cobra.Command{
Available git config:
git-bug.webui.open [bool]: control the automatic opening of the web UI in the default browser
`,
- PreRunE: loadRepoEnsureUser,
+ PreRunE: loadRepo,
RunE: runWebUI,
}
@@ -261,5 +158,5 @@ func init() {
webUICmd.Flags().BoolVar(&webUIOpen, "open", false, "Automatically open the web UI in the default browser")
webUICmd.Flags().BoolVar(&webUINoOpen, "no-open", false, "Prevent the automatic opening of the web UI in the default browser")
webUICmd.Flags().IntVarP(&webUIPort, "port", "p", 0, "Port to listen to (default is random)")
-
+ webUICmd.Flags().BoolVar(&webUIReadOnly, "read-only", false, "Whether to run the web UI in read-only mode")
}
@@ -34,6 +34,10 @@ Available git config:
\fB\-p\fP, \fB\-\-port\fP=0
Port to listen to (default is random)
+.PP
+\fB\-\-read\-only\fP[=false]
+ Whether to run the web UI in read\-only mode
+
.PP
\fB\-h\fP, \fB\-\-help\fP[=false]
help for webui
@@ -17,10 +17,11 @@ git-bug webui [flags]
### Options
```
- --open Automatically open the web UI in the default browser
- --no-open Prevent the automatic opening of the web UI in the default browser
- -p, --port int Port to listen to (default is random)
- -h, --help help for webui
+ --open Automatically open the web UI in the default browser
+ --no-open Prevent the automatic opening of the web UI in the default browser
+ -p, --port int Port to listen to (default is random)
+ --read-only Whether to run the web UI in read-only mode
+ -h, --help help for webui
```
### SEE ALSO
@@ -1,39 +0,0 @@
-//go:generate go run gen_graphql.go
-
-// Package graphql contains the root GraphQL http handler
-package graphql
-
-import (
- "net/http"
-
- "github.com/99designs/gqlgen/graphql/handler"
-
- "github.com/MichaelMure/git-bug/graphql/graph"
- "github.com/MichaelMure/git-bug/graphql/resolvers"
- "github.com/MichaelMure/git-bug/repository"
-)
-
-// Handler is the root GraphQL http handler
-type Handler struct {
- http.Handler
- *resolvers.RootResolver
-}
-
-func NewHandler(repo repository.ClockedRepo) (Handler, error) {
- h := Handler{
- RootResolver: resolvers.NewRootResolver(),
- }
-
- err := h.RootResolver.RegisterDefaultRepository(repo)
- if err != nil {
- return Handler{}, err
- }
-
- config := graph.Config{
- Resolvers: h.RootResolver,
- }
-
- h.Handler = handler.NewDefaultServer(graph.NewExecutableSchema(config))
-
- return h, nil
-}
@@ -1213,6 +1213,8 @@ _git-bug_webui()
two_word_flags+=("--port")
two_word_flags+=("-p")
local_nonpersistent_flags+=("--port=")
+ flags+=("--read-only")
+ local_nonpersistent_flags+=("--read-only")
must_have_one_flag=()
must_have_one_noun=()
@@ -240,6 +240,7 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock {
[CompletionResult]::new('--no-open', 'no-open', [CompletionResultType]::ParameterName, 'Prevent the automatic opening of the web UI in the default browser')
[CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Port to listen to (default is random)')
[CompletionResult]::new('--port', 'port', [CompletionResultType]::ParameterName, 'Port to listen to (default is random)')
+ [CompletionResult]::new('--read-only', 'read-only', [CompletionResultType]::ParameterName, 'Whether to run the web UI in read-only mode')
break
}
})
@@ -459,6 +459,7 @@ function _git-bug_webui {
_arguments \
'--open[Automatically open the web UI in the default browser]' \
'--no-open[Prevent the automatic opening of the web UI in the default browser]' \
- '(-p --port)'{-p,--port}'[Port to listen to (default is random)]:'
+ '(-p --port)'{-p,--port}'[Port to listen to (default is random)]:' \
+ '--read-only[Whether to run the web UI in read-only mode]'
}
@@ -1,4 +1,4 @@
-schema: '../graphql/schema/*.graphql'
+schema: '../api/graphql/schema/*.graphql'
overwrite: true
documents: src/**/*.graphql
generates:
@@ -0,0 +1,31 @@
+package webui
+
+import (
+ "net/http"
+ "os"
+)
+
+// implement a http.FileSystem that will serve a default file when the looked up
+// file doesn't exist. Useful for Single-Page App that implement routing client
+// side, where the server has to return the root index.html file for every route.
+type fileSystemWithDefault struct {
+ http.FileSystem
+ defaultFile string
+}
+
+func (fswd *fileSystemWithDefault) Open(name string) (http.File, error) {
+ f, err := fswd.FileSystem.Open(name)
+ if os.IsNotExist(err) {
+ return fswd.FileSystem.Open(fswd.defaultFile)
+ }
+ return f, err
+}
+
+func NewHandler() http.Handler {
+ assetsHandler := &fileSystemWithDefault{
+ FileSystem: WebUIAssets,
+ defaultFile: "index.html",
+ }
+
+ return http.FileServer(assetsHandler)
+}
@@ -21,92 +21,92 @@ var WebUIAssets = func() http.FileSystem {
fs := vfsgenΫ°FS{
"/": &vfsgenΫ°DirInfo{
name: "/",
- modTime: time.Date(2020, 2, 23, 15, 30, 55, 841734699, time.UTC),
+ modTime: time.Date(2020, 6, 27, 21, 4, 34, 651378504, time.UTC),
},
"/asset-manifest.json": &vfsgenΫ°CompressedFileInfo{
name: "asset-manifest.json",
- modTime: time.Date(2020, 2, 23, 15, 30, 55, 841734699, time.UTC),
+ modTime: time.Date(2020, 6, 27, 21, 4, 34, 651378504, time.UTC),
uncompressedSize: 849,
@@ -0,0 +1,14 @@
+import React from 'react';
+
+import { useCurrentIdentityQuery } from './CurrentIdentity.generated';
+
+type Props = { children: () => React.ReactNode };
+const IfLoggedIn = ({ children }: Props) => {
+ const { loading, error, data } = useCurrentIdentityQuery();
+
+ if (error || loading || !data?.repository?.userIdentity) return null;
+
+ return <>{children()}</>;
+};
+
+export default IfLoggedIn;
@@ -6,6 +6,7 @@ import { makeStyles } from '@material-ui/core/styles';
import Author from 'src/components/Author';
import Date from 'src/components/Date';
import Label from 'src/components/Label';
+import IfLoggedIn from 'src/layout/IfLoggedIn';
import { BugFragment } from './Bug.generated';
import CommentForm from './CommentForm';
@@ -88,9 +89,13 @@ function Bug({ bug }: Props) {
<div className={classes.container}>
<div className={classes.timeline}>
<TimelineQuery id={bug.id} />
- <div className={classes.commentForm}>
- <CommentForm bugId={bug.id} />
- </div>
+ <IfLoggedIn>
+ {() => (
+ <div className={classes.commentForm}>
+ <CommentForm bugId={bug.id} />
+ </div>
+ )}
+ </IfLoggedIn>
</div>
<div className={classes.sidebar}>
<span className={classes.sidebarTitle}>Labels</span>