From 49c2e216d2d7754c2b9f15a31b41e106fe0b83bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Fri, 13 Mar 2026 21:52:39 +0100 Subject: [PATCH] snapshot --- api/auth/middleware.go | 13 ++ api/http/git_browse_handler.go | 183 ++++++++++++++-------- api/http/git_file_handler.go | 10 +- api/http/git_file_upload_handler.go | 19 +-- commands/webui.go | 31 ++-- webui2/src/__generated__/graphql.ts | 62 +++++++- webui2/src/components/code/FileViewer.tsx | 6 +- webui2/src/lib/gitApi.ts | 25 +-- webui2/src/pages/CodePage.tsx | 2 +- webui2/src/pages/IdentitySelectPage.tsx | 2 - webui2/tsconfig.app.tsbuildinfo | 2 +- 11 files changed, 243 insertions(+), 112 deletions(-) diff --git a/api/auth/middleware.go b/api/auth/middleware.go index 3eef5ae5e6f984f903c4ade657ae291796abcdab..1c2c9a3629102b1b14b0fb5d6cce8ec8c5d822d0 100644 --- a/api/auth/middleware.go +++ b/api/auth/middleware.go @@ -18,6 +18,19 @@ func Middleware(fixedUserId entity.Id) func(http.Handler) http.Handler { } } +// RequireAuth is middleware that rejects unauthenticated requests with 401. +// Use this on subrouters that must never be accessible without a valid session +// (e.g. the REST API in oauth mode when the server is publicly deployed). +func RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := r.Context().Value(identityCtxKey).(entity.Id); !ok { + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + // SessionMiddleware reads the session cookie on every request and, when a // valid session exists, injects the corresponding identity ID into the context. // diff --git a/api/http/git_browse_handler.go b/api/http/git_browse_handler.go index 658efeb4090f90928e24ea0720ec84b0c0825c77..84933510dd3b2d54be48c8c24bccd8a856f83fd6 100644 --- a/api/http/git_browse_handler.go +++ b/api/http/git_browse_handler.go @@ -7,6 +7,8 @@ import ( "strconv" "strings" + "github.com/gorilla/mux" + "github.com/git-bug/git-bug/cache" "github.com/git-bug/git-bug/repository" ) @@ -20,10 +22,20 @@ func writeJSON(w http.ResponseWriter, v any) { } } -// browseRepo resolves the default repo from the cache and type-asserts to -// RepoBrowse. All four handlers use this helper. -func browseRepo(mrc *cache.MultiRepoCache) (repository.ClockedRepo, repository.RepoBrowse, error) { - rc, err := mrc.DefaultRepo() +// repoFromPath resolves the repository from the {owner} and {repo} mux path +// variables. "_" is the wildcard value: owner is always ignored (single-owner +// for now), and repo "_" resolves to the default repository. +func repoFromPath(mrc *cache.MultiRepoCache, r *http.Request) (*cache.RepoCache, error) { + repoVar := mux.Vars(r)["repo"] + if repoVar == "_" { + return mrc.DefaultRepo() + } + return mrc.ResolveRepo(repoVar) +} + +// browseRepo resolves the repository and asserts it implements RepoBrowse. +func browseRepo(mrc *cache.MultiRepoCache, r *http.Request) (repository.ClockedRepo, repository.RepoBrowse, error) { + rc, err := repoFromPath(mrc, r) if err != nil { return nil, nil, err } @@ -35,8 +47,18 @@ func browseRepo(mrc *cache.MultiRepoCache) (repository.ClockedRepo, repository.R return underlying, br, nil } +// resolveRef tries refs/heads/, refs/tags/, then a raw hash. +func resolveRef(repo repository.ClockedRepo, ref string) (repository.Hash, error) { + for _, prefix := range []string{"refs/heads/", "refs/tags/", ""} { + h, err := repo.ResolveRef(prefix + ref) + if err == nil { + return h, nil + } + } + return "", repository.ErrNotFound +} + // resolveTreeAtPath walks the git tree of a commit down to the given path. -// path may be empty (returns root tree entries) or a slash-separated directory path. func resolveTreeAtPath(repo repository.ClockedRepo, commitHash repository.Hash, path string) ([]repository.TreeEntry, error) { commit, err := repo.ReadCommit(commitHash) if err != nil { @@ -92,7 +114,17 @@ func resolveBlobAtPath(repo repository.ClockedRepo, commitHash repository.Hash, return entry.Hash, nil } -// ── GET /api/git/refs ───────────────────────────────────────────────────────── +// isBinaryContent returns true if data contains a null byte (simple heuristic). +func isBinaryContent(data []byte) bool { + for _, b := range data { + if b == 0 { + return true + } + } + return false +} + +// ── GET /api/repos/{owner}/{repo}/git/refs ──────────────────────────────────── type gitRefsHandler struct{ mrc *cache.MultiRepoCache } @@ -109,7 +141,7 @@ type refResponse struct { } func (h *gitRefsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - repo, br, err := browseRepo(h.mrc) + repo, br, err := browseRepo(h.mrc, r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -118,7 +150,6 @@ func (h *gitRefsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { defaultBranch, _ := br.GetDefaultBranch() var refs []refResponse - for _, prefix := range []string{"refs/heads/", "refs/tags/"} { names, err := repo.ListRefs(prefix) if err != nil { @@ -148,7 +179,7 @@ func (h *gitRefsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeJSON(w, refs) } -// ── GET /api/git/tree ───────────────────────────────────────────────────────── +// ── GET /api/repos/{owner}/{repo}/git/trees/{ref}?path= ────────────────────── type gitTreeHandler struct{ mrc *cache.MultiRepoCache } @@ -165,15 +196,10 @@ type treeEntryResponse struct { } func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ref := r.URL.Query().Get("ref") + ref := mux.Vars(r)["ref"] path := r.URL.Query().Get("path") - if ref == "" { - http.Error(w, "missing ref", http.StatusBadRequest) - return - } - - repo, br, err := browseRepo(h.mrc) + repo, br, err := browseRepo(h.mrc, r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -181,7 +207,7 @@ func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { commitHash, err := resolveRef(repo, ref) if err != nil { - http.Error(w, "ref not found: "+ref, http.StatusNotFound) + http.Error(w, "ref not found", http.StatusNotFound) return } @@ -195,12 +221,11 @@ func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Collect entry names and fetch last commits in one shallow history pass. names := make([]string, len(entries)) for i, e := range entries { names[i] = e.Name } - lastCommits, _ := br.LastCommitForEntries(ref, path, names) // best-effort + lastCommits, _ := br.LastCommitForEntries(ref, path, names) resp := make([]treeEntryResponse, 0, len(entries)) for _, e := range entries { @@ -210,7 +235,6 @@ func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { objType = "tree" mode = "040000" } - item := treeEntryResponse{ Name: e.Name, Type: objType, @@ -220,14 +244,13 @@ func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if cm, ok := lastCommits[e.Name]; ok { item.LastCommit = toCommitMetaResponse(cm) } - resp = append(resp, item) } writeJSON(w, resp) } -// ── GET /api/git/blob ───────────────────────────────────────────────────────── +// ── GET /api/repos/{owner}/{repo}/git/blobs/{ref}?path= ────────────────────── type gitBlobHandler struct{ mrc *cache.MultiRepoCache } @@ -243,15 +266,15 @@ type blobResponse struct { } func (h *gitBlobHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ref := r.URL.Query().Get("ref") + ref := mux.Vars(r)["ref"] path := r.URL.Query().Get("path") - if ref == "" || path == "" { - http.Error(w, "missing ref or path", http.StatusBadRequest) + if path == "" { + http.Error(w, "missing path", http.StatusBadRequest) return } - repo, _, err := browseRepo(h.mrc) + repo, _, err := browseRepo(h.mrc, r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -259,7 +282,7 @@ func (h *gitBlobHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { commitHash, err := resolveRef(repo, ref) if err != nil { - http.Error(w, "ref not found: "+ref, http.StatusNotFound) + http.Error(w, "ref not found", http.StatusNotFound) return } @@ -293,7 +316,62 @@ func (h *gitBlobHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { }) } -// ── GET /api/git/commits ────────────────────────────────────────────────────── +// ── GET /api/repos/{owner}/{repo}/git/raw/{ref}/{path} ─────────────────────── +// Serves the raw file content for download. ref and path are both in the URL +// path, producing human-readable download URLs like: +// +// /api/repos/_/_/git/raw/main/src/foo/bar.go + +type gitRawHandler struct{ mrc *cache.MultiRepoCache } + +func NewGitRawHandler(mrc *cache.MultiRepoCache) http.Handler { + return &gitRawHandler{mrc: mrc} +} + +func (h *gitRawHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ref := mux.Vars(r)["ref"] + path := mux.Vars(r)["path"] + + if path == "" { + http.Error(w, "missing path", http.StatusBadRequest) + return + } + + repo, _, err := browseRepo(h.mrc, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + commitHash, err := resolveRef(repo, ref) + if err != nil { + http.Error(w, "ref not found", http.StatusNotFound) + return + } + + blobHash, err := resolveBlobAtPath(repo, commitHash, path) + if err == repository.ErrNotFound { + http.Error(w, "path not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + data, err := repo.ReadData(blobHash) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + fileName := path[strings.LastIndex(path, "/")+1:] + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, fileName)) + w.Header().Set("Content-Type", "application/octet-stream") + w.Write(data) +} + +// ── GET /api/repos/{owner}/{repo}/git/commits?ref=&path=&limit=&after= ─────── type gitCommitsHandler struct{ mrc *cache.MultiRepoCache } @@ -302,12 +380,12 @@ func NewGitCommitsHandler(mrc *cache.MultiRepoCache) http.Handler { } type commitMetaResponse struct { - Hash string `json:"hash"` - ShortHash string `json:"shortHash"` - Message string `json:"message"` - AuthorName string `json:"authorName"` - AuthorEmail string `json:"authorEmail"` - Date string `json:"date"` // RFC3339 + Hash string `json:"hash"` + ShortHash string `json:"shortHash"` + Message string `json:"message"` + AuthorName string `json:"authorName"` + AuthorEmail string `json:"authorEmail"` + Date string `json:"date"` // RFC3339 Parents []string `json:"parents"` } @@ -344,7 +422,7 @@ func (h *gitCommitsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - _, br, err := browseRepo(h.mrc) + _, br, err := browseRepo(h.mrc, r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -363,7 +441,7 @@ func (h *gitCommitsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeJSON(w, resp) } -// ── GET /api/git/commit ─────────────────────────────────────────────────────── +// ── GET /api/repos/{owner}/{repo}/git/commits/{sha} ────────────────────────── type gitCommitHandler struct{ mrc *cache.MultiRepoCache } @@ -384,19 +462,15 @@ type commitDetailResponse struct { } func (h *gitCommitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - hash := r.URL.Query().Get("hash") - if hash == "" { - http.Error(w, "missing hash", http.StatusBadRequest) - return - } + sha := mux.Vars(r)["sha"] - _, br, err := browseRepo(h.mrc) + _, br, err := browseRepo(h.mrc, r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - detail, err := br.CommitDetail(repository.Hash(hash)) + detail, err := br.CommitDetail(repository.Hash(sha)) if err == repository.ErrNotFound { http.Error(w, "commit not found", http.StatusNotFound) return @@ -417,26 +491,3 @@ func (h *gitCommitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { Files: files, }) } - -// ── utilities ───────────────────────────────────────────────────────────────── - -// resolveRef tries refs/heads/, refs/tags/, then raw hash. -func resolveRef(repo repository.ClockedRepo, ref string) (repository.Hash, error) { - for _, prefix := range []string{"refs/heads/", "refs/tags/", ""} { - h, err := repo.ResolveRef(prefix + ref) - if err == nil { - return h, nil - } - } - return "", repository.ErrNotFound -} - -// isBinaryContent returns true if data contains a null byte (simple heuristic). -func isBinaryContent(data []byte) bool { - for _, b := range data { - if b == 0 { - return true - } - } - return false -} diff --git a/api/http/git_file_handler.go b/api/http/git_file_handler.go index 5db54a5228c8f1604a4815733e03b0364d583be9..b28c4ff46049ff73b33c59e50d13b87ea71e9610 100644 --- a/api/http/git_file_handler.go +++ b/api/http/git_file_handler.go @@ -14,8 +14,9 @@ import ( // 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 +// - "owner" : ignored (reserved for future multi-owner support); "_" for local +// - "repo" : the name of the repo, or "_" for the default one +// - "hash" : the git hash of the file to retrieve type gitFileHandler struct { mrc *cache.MultiRepoCache } @@ -29,10 +30,9 @@ func (gfh *gitFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { var err error repoVar := mux.Vars(r)["repo"] - switch repoVar { - case "": + if repoVar == "_" { repo, err = gfh.mrc.DefaultRepo() - default: + } else { repo, err = gfh.mrc.ResolveRepo(repoVar) } diff --git a/api/http/git_file_upload_handler.go b/api/http/git_file_upload_handler.go index 9aa95def87dbff88786be1ee00a405f71f79bcf9..7c66f903250439db3da83fa8beb24324823ea905 100644 --- a/api/http/git_file_upload_handler.go +++ b/api/http/git_file_upload_handler.go @@ -2,20 +2,19 @@ package http import ( "encoding/json" - "fmt" "io" "net/http" "github.com/gorilla/mux" - "github.com/git-bug/git-bug/api/auth" "github.com/git-bug/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 +// - "owner" : ignored (reserved for future multi-owner support); "_" for local +// - "repo" : the name of the repo, or "_" for the default one type gitUploadFileHandler struct { mrc *cache.MultiRepoCache } @@ -29,10 +28,9 @@ func (gufh *gitUploadFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Requ var err error repoVar := mux.Vars(r)["repo"] - switch repoVar { - case "": + if repoVar == "_" { repo, err = gufh.mrc.DefaultRepo() - default: + } else { repo, err = gufh.mrc.ResolveRepo(repoVar) } @@ -41,15 +39,6 @@ func (gufh *gitUploadFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Requ 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) diff --git a/commands/webui.go b/commands/webui.go index 572e6b91b8f005a41b49283392b74dfb1a1c1783..fbb01a14993dbf123b91e399d089f555f42dc61b 100644 --- a/commands/webui.go +++ b/commands/webui.go @@ -172,17 +172,30 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error { router.Path("/auth/adopt").Methods("POST").HandlerFunc(ah.HandleAdopt) } - // Routes + // Top-level API routes router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql")) router.Path("/graphql").Handler(graphqlHandler) - router.Path("/gitfile/{repo}/{hash}").Handler(httpapi.NewGitFileHandler(mrc)) - router.Path("/upload/{repo}").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc)) - // Git browsing API (used by the code browser UI) - router.Path("/api/git/refs").Methods("GET").Handler(httpapi.NewGitRefsHandler(mrc)) - router.Path("/api/git/tree").Methods("GET").Handler(httpapi.NewGitTreeHandler(mrc)) - router.Path("/api/git/blob").Methods("GET").Handler(httpapi.NewGitBlobHandler(mrc)) - router.Path("/api/git/commits").Methods("GET").Handler(httpapi.NewGitCommitsHandler(mrc)) - router.Path("/api/git/commit").Methods("GET").Handler(httpapi.NewGitCommitHandler(mrc)) + + // /api/repos/{owner}/{repo}/ subrouter. + // owner is reserved for future use; "_" means "local". + // repo "_" resolves to the default repository. + // + // In oauth mode all API endpoints require a valid session, making the + // server safe to deploy publicly. In local and readonly modes the + // middleware only injects identity without blocking. + apiRepos := router.PathPrefix("/api/repos/{owner}/{repo}").Subrouter() + if authMode == "oauth" { + apiRepos.Use(auth.RequireAuth) + } + apiRepos.Path("/git/refs").Methods("GET").Handler(httpapi.NewGitRefsHandler(mrc)) + apiRepos.Path("/git/trees/{ref}").Methods("GET").Handler(httpapi.NewGitTreeHandler(mrc)) + apiRepos.Path("/git/blobs/{ref}").Methods("GET").Handler(httpapi.NewGitBlobHandler(mrc)) + apiRepos.Path("/git/raw/{ref}/{path:.*}").Methods("GET").Handler(httpapi.NewGitRawHandler(mrc)) + apiRepos.Path("/git/commits").Methods("GET").Handler(httpapi.NewGitCommitsHandler(mrc)) + apiRepos.Path("/git/commits/{sha}").Methods("GET").Handler(httpapi.NewGitCommitHandler(mrc)) + apiRepos.Path("/file/{hash}").Methods("GET").Handler(httpapi.NewGitFileHandler(mrc)) + apiRepos.Path("/upload").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc)) + router.PathPrefix("/").Handler(webui.NewHandler()) srv := &http.Server{ diff --git a/webui2/src/__generated__/graphql.ts b/webui2/src/__generated__/graphql.ts index 1c827fad06d2784daed73ad5c7d3020fae2bc558..a0daece0782c87618d10f81a6d4524b05225e6c9 100644 --- a/webui2/src/__generated__/graphql.ts +++ b/webui2/src/__generated__/graphql.ts @@ -26,7 +26,7 @@ export type Authored = { author: Identity; }; -export type Bug = Authored & { +export type Bug = Authored & Entity & { __typename?: 'Bug'; /** The actors of the bug. Actors are Identity that have interacted with the bug. */ actors: IdentityConnection; @@ -356,6 +356,12 @@ export type BugEditCommentPayload = { operation: BugEditCommentOperation; }; +export type BugEvent = { + __typename?: 'BugEvent'; + bug: Bug; + type: EntityEventType; +}; + export type BugLabelChangeOperation = Authored & Operation & { __typename?: 'BugLabelChangeOperation'; added: Array