From 1d290c4471d996ec545b97e13d92282ea21e0efd Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Thu, 6 Nov 2025 07:22:12 +0000 Subject: [PATCH] appview/repo: split up handlers into separate files Change-Id: qwwtnptuywslvpnumltmwpyopwlwmtsq Signed-off-by: oppiliappan --- appview/repo/archive.go | 49 ++ appview/repo/blob.go | 219 ++++++ appview/repo/branches.go | 95 +++ appview/repo/compare.go | 214 ++++++ appview/repo/feed.go | 2 +- appview/repo/index.go | 2 +- appview/repo/log.go | 223 ++++++ appview/repo/opengraph.go | 2 +- appview/repo/repo.go | 1510 ++----------------------------------- appview/repo/router.go | 28 +- appview/repo/settings.go | 442 +++++++++++ appview/repo/tags.go | 79 ++ appview/repo/tree.go | 107 +++ 13 files changed, 1516 insertions(+), 1456 deletions(-) create mode 100644 appview/repo/archive.go create mode 100644 appview/repo/blob.go create mode 100644 appview/repo/branches.go create mode 100644 appview/repo/compare.go create mode 100644 appview/repo/log.go create mode 100644 appview/repo/settings.go create mode 100644 appview/repo/tags.go create mode 100644 appview/repo/tree.go diff --git a/appview/repo/archive.go b/appview/repo/archive.go new file mode 100644 index 00000000..3d3ee729 --- /dev/null +++ b/appview/repo/archive.go @@ -0,0 +1,49 @@ +package repo + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "tangled.org/core/api/tangled" + xrpcclient "tangled.org/core/appview/xrpcclient" + + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "github.com/go-chi/chi/v5" + "github.com/go-git/go-git/v5/plumbing" +) + +func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "DownloadArchive") + ref := chi.URLParam(r, "ref") + ref, _ = url.PathUnescape(ref) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + host := fmt.Sprintf("%s://%s", scheme, f.Knot) + xrpcc := &indigoxrpc.Client{ + Host: host, + } + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) + rp.pages.Error503(w) + return + } + // Set headers for file download, just pass along whatever the knot specifies + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) + // Write the archive data directly + w.Write(archiveBytes) +} diff --git a/appview/repo/blob.go b/appview/repo/blob.go new file mode 100644 index 00000000..9aebcb1b --- /dev/null +++ b/appview/repo/blob.go @@ -0,0 +1,219 @@ +package repo + +import ( + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "slices" + "strings" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/pages" + "tangled.org/core/appview/pages/markup" + xrpcclient "tangled.org/core/appview/xrpcclient" + + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "github.com/go-chi/chi/v5" +) + +func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "RepoBlob") + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + ref := chi.URLParam(r, "ref") + ref, _ = url.PathUnescape(ref) + filePath := chi.URLParam(r, "*") + filePath, _ = url.PathUnescape(filePath) + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + host := fmt.Sprintf("%s://%s", scheme, f.Knot) + xrpcc := &indigoxrpc.Client{ + Host: host, + } + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) + rp.pages.Error503(w) + return + } + // Use XRPC response directly instead of converting to internal types + var breadcrumbs [][]string + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) + if filePath != "" { + for idx, elem := range strings.Split(filePath, "/") { + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) + } + } + showRendered := false + renderToggle := false + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { + renderToggle = true + showRendered = r.URL.Query().Get("code") != "true" + } + var unsupported bool + var isImage bool + var isVideo bool + var contentSrc string + if resp.IsBinary != nil && *resp.IsBinary { + ext := strings.ToLower(filepath.Ext(resp.Path)) + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": + isImage = true + case ".mp4", ".webm", ".ogg", ".mov", ".avi": + isVideo = true + default: + unsupported = true + } + // fetch the raw binary content using sh.tangled.repo.blob xrpc + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) + baseURL := &url.URL{ + Scheme: scheme, + Host: f.Knot, + Path: "/xrpc/sh.tangled.repo.blob", + } + query := baseURL.Query() + query.Set("repo", repoName) + query.Set("ref", ref) + query.Set("path", filePath) + query.Set("raw", "true") + baseURL.RawQuery = query.Encode() + blobURL := baseURL.String() + contentSrc = blobURL + if !rp.config.Core.Dev { + contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) + } + } + lines := 0 + if resp.IsBinary == nil || !*resp.IsBinary { + lines = strings.Count(resp.Content, "\n") + 1 + } + var sizeHint uint64 + if resp.Size != nil { + sizeHint = uint64(*resp.Size) + } else { + sizeHint = uint64(len(resp.Content)) + } + user := rp.oauth.GetUser(r) + // Determine if content is binary (dereference pointer) + isBinary := false + if resp.IsBinary != nil { + isBinary = *resp.IsBinary + } + rp.pages.RepoBlob(w, pages.RepoBlobParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + BreadCrumbs: breadcrumbs, + ShowRendered: showRendered, + RenderToggle: renderToggle, + Unsupported: unsupported, + IsImage: isImage, + IsVideo: isVideo, + ContentSrc: contentSrc, + RepoBlob_Output: resp, + Contents: resp.Content, + Lines: lines, + SizeHint: sizeHint, + IsBinary: isBinary, + }) +} + +func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "RepoBlobRaw") + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + w.WriteHeader(http.StatusBadRequest) + return + } + ref := chi.URLParam(r, "ref") + ref, _ = url.PathUnescape(ref) + filePath := chi.URLParam(r, "*") + filePath, _ = url.PathUnescape(filePath) + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) + baseURL := &url.URL{ + Scheme: scheme, + Host: f.Knot, + Path: "/xrpc/sh.tangled.repo.blob", + } + query := baseURL.Query() + query.Set("repo", repo) + query.Set("ref", ref) + query.Set("path", filePath) + query.Set("raw", "true") + baseURL.RawQuery = query.Encode() + blobURL := baseURL.String() + req, err := http.NewRequest("GET", blobURL, nil) + if err != nil { + l.Error("failed to create request", "err", err) + return + } + // forward the If-None-Match header + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { + req.Header.Set("If-None-Match", clientETag) + } + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + l.Error("failed to reach knotserver", "err", err) + rp.pages.Error503(w) + return + } + defer resp.Body.Close() + // forward 304 not modified + if resp.StatusCode == http.StatusNotModified { + w.WriteHeader(http.StatusNotModified) + return + } + if resp.StatusCode != http.StatusOK { + l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + return + } + contentType := resp.Header.Get("Content-Type") + body, err := io.ReadAll(resp.Body) + if err != nil { + l.Error("error reading response body from knotserver", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { + // serve all textual content as text/plain + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write(body) + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { + // serve images and videos with their original content type + w.Header().Set("Content-Type", contentType) + w.Write(body) + } else { + w.WriteHeader(http.StatusUnsupportedMediaType) + w.Write([]byte("unsupported content type")) + return + } +} + +func isTextualMimeType(mimeType string) bool { + textualTypes := []string{ + "application/json", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/toml", + "application/javascript", + "application/ecmascript", + "message/", + } + return slices.Contains(textualTypes, mimeType) +} diff --git a/appview/repo/branches.go b/appview/repo/branches.go new file mode 100644 index 00000000..ffd9baab --- /dev/null +++ b/appview/repo/branches.go @@ -0,0 +1,95 @@ +package repo + +import ( + "encoding/json" + "fmt" + "net/http" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/oauth" + "tangled.org/core/appview/pages" + xrpcclient "tangled.org/core/appview/xrpcclient" + "tangled.org/core/types" + + indigoxrpc "github.com/bluesky-social/indigo/xrpc" +) + +func (rp *Repo) Branches(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "RepoBranches") + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + host := fmt.Sprintf("%s://%s", scheme, f.Knot) + xrpcc := &indigoxrpc.Client{ + Host: host, + } + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) + rp.pages.Error503(w) + return + } + var result types.RepoBranchesResponse + if err := json.Unmarshal(xrpcBytes, &result); err != nil { + l.Error("failed to decode XRPC response", "err", err) + rp.pages.Error503(w) + return + } + sortBranches(result.Branches) + user := rp.oauth.GetUser(r) + rp.pages.RepoBranches(w, pages.RepoBranchesParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + RepoBranchesResponse: result, + }) +} + +func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "DeleteBranch") + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + noticeId := "delete-branch-error" + fail := func(msg string, err error) { + l.Error(msg, "err", err) + rp.pages.Notice(w, noticeId, msg) + } + branch := r.FormValue("branch") + if branch == "" { + fail("No branch provided.", nil) + return + } + client, err := rp.oauth.ServiceClient( + r, + oauth.WithService(f.Knot), + oauth.WithLxm(tangled.RepoDeleteBranchNSID), + oauth.WithDev(rp.config.Core.Dev), + ) + if err != nil { + fail("Failed to connect to knotserver", nil) + return + } + err = tangled.RepoDeleteBranch( + r.Context(), + client, + &tangled.RepoDeleteBranch_Input{ + Branch: branch, + Repo: f.RepoAt().String(), + }, + ) + if err := xrpcclient.HandleXrpcErr(err); err != nil { + fail(fmt.Sprintf("Failed to delete branch: %s", err), err) + return + } + l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) + rp.pages.HxRefresh(w) +} diff --git a/appview/repo/compare.go b/appview/repo/compare.go new file mode 100644 index 00000000..6139ae9d --- /dev/null +++ b/appview/repo/compare.go @@ -0,0 +1,214 @@ +package repo + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/pages" + xrpcclient "tangled.org/core/appview/xrpcclient" + "tangled.org/core/patchutil" + "tangled.org/core/types" + + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "github.com/go-chi/chi/v5" +) + +func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "RepoCompareNew") + + user := rp.oauth.GetUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + host := fmt.Sprintf("%s://%s", scheme, f.Knot) + xrpcc := &indigoxrpc.Client{ + Host: host, + } + + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) + rp.pages.Error503(w) + return + } + + var branchResult types.RepoBranchesResponse + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { + l.Error("failed to decode XRPC branches response", "err", err) + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") + return + } + branches := branchResult.Branches + + sortBranches(branches) + + var defaultBranch string + for _, b := range branches { + if b.IsDefault { + defaultBranch = b.Name + } + } + + base := defaultBranch + head := defaultBranch + + params := r.URL.Query() + queryBase := params.Get("base") + queryHead := params.Get("head") + if queryBase != "" { + base = queryBase + } + if queryHead != "" { + head = queryHead + } + + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) + rp.pages.Error503(w) + return + } + + var tags types.RepoTagsResponse + if err := json.Unmarshal(tagBytes, &tags); err != nil { + l.Error("failed to decode XRPC tags response", "err", err) + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") + return + } + + repoinfo := f.RepoInfo(user) + + rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ + LoggedInUser: user, + RepoInfo: repoinfo, + Branches: branches, + Tags: tags.Tags, + Base: base, + Head: head, + }) +} + +func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "RepoCompare") + + user := rp.oauth.GetUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + + var diffOpts types.DiffOpts + if d := r.URL.Query().Get("diff"); d == "split" { + diffOpts.Split = true + } + + // if user is navigating to one of + // /compare/{base}/{head} + // /compare/{base}...{head} + base := chi.URLParam(r, "base") + head := chi.URLParam(r, "head") + if base == "" && head == "" { + rest := chi.URLParam(r, "*") // master...feature/xyz + parts := strings.SplitN(rest, "...", 2) + if len(parts) == 2 { + base = parts[0] + head = parts[1] + } + } + + base, _ = url.PathUnescape(base) + head, _ = url.PathUnescape(head) + + if base == "" || head == "" { + l.Error("invalid comparison") + rp.pages.Error404(w) + return + } + + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + host := fmt.Sprintf("%s://%s", scheme, f.Knot) + xrpcc := &indigoxrpc.Client{ + Host: host, + } + + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) + + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) + rp.pages.Error503(w) + return + } + + var branches types.RepoBranchesResponse + if err := json.Unmarshal(branchBytes, &branches); err != nil { + l.Error("failed to decode XRPC branches response", "err", err) + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") + return + } + + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) + rp.pages.Error503(w) + return + } + + var tags types.RepoTagsResponse + if err := json.Unmarshal(tagBytes, &tags); err != nil { + l.Error("failed to decode XRPC tags response", "err", err) + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") + return + } + + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.compare", "err", xrpcerr) + rp.pages.Error503(w) + return + } + + var formatPatch types.RepoFormatPatchResponse + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { + l.Error("failed to decode XRPC compare response", "err", err) + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") + return + } + + var diff types.NiceDiff + if formatPatch.CombinedPatchRaw != "" { + diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) + } else { + diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) + } + + repoinfo := f.RepoInfo(user) + + rp.pages.RepoCompare(w, pages.RepoCompareParams{ + LoggedInUser: user, + RepoInfo: repoinfo, + Branches: branches.Branches, + Tags: tags.Tags, + Base: base, + Head: head, + Diff: &diff, + DiffOpts: diffOpts, + }) + +} diff --git a/appview/repo/feed.go b/appview/repo/feed.go index 31d194e3..7d23faa0 100644 --- a/appview/repo/feed.go +++ b/appview/repo/feed.go @@ -146,7 +146,7 @@ func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *m return fmt.Sprintf("%s in %s", base, repoName) } -func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { +func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) { f, err := rp.repoResolver.Resolve(r) if err != nil { log.Println("failed to fully resolve repo:", err) diff --git a/appview/repo/index.go b/appview/repo/index.go index 23e09c88..218b9777 100644 --- a/appview/repo/index.go +++ b/appview/repo/index.go @@ -30,7 +30,7 @@ import ( "github.com/go-enry/go-enry/v2" ) -func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { +func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "RepoIndex") ref := chi.URLParam(r, "ref") diff --git a/appview/repo/log.go b/appview/repo/log.go new file mode 100644 index 00000000..89b22405 --- /dev/null +++ b/appview/repo/log.go @@ -0,0 +1,223 @@ +package repo + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/commitverify" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pages" + xrpcclient "tangled.org/core/appview/xrpcclient" + "tangled.org/core/types" + + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "github.com/go-chi/chi/v5" + "github.com/go-git/go-git/v5/plumbing" +) + +func (rp *Repo) Log(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "RepoLog") + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to fully resolve repo", "err", err) + return + } + + page := 1 + if r.URL.Query().Get("page") != "" { + page, err = strconv.Atoi(r.URL.Query().Get("page")) + if err != nil { + page = 1 + } + } + + ref := chi.URLParam(r, "ref") + ref, _ = url.PathUnescape(ref) + + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + host := fmt.Sprintf("%s://%s", scheme, f.Knot) + xrpcc := &indigoxrpc.Client{ + Host: host, + } + + limit := int64(60) + cursor := "" + if page > 1 { + // Convert page number to cursor (offset) + offset := (page - 1) * int(limit) + cursor = strconv.Itoa(offset) + } + + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) + xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.log", "err", xrpcerr) + rp.pages.Error503(w) + return + } + + var xrpcResp types.RepoLogResponse + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { + l.Error("failed to decode XRPC response", "err", err) + rp.pages.Error503(w) + return + } + + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) + rp.pages.Error503(w) + return + } + + tagMap := make(map[string][]string) + if tagBytes != nil { + var tagResp types.RepoTagsResponse + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { + for _, tag := range tagResp.Tags { + hash := tag.Hash + if tag.Tag != nil { + hash = tag.Tag.Target.String() + } + tagMap[hash] = append(tagMap[hash], tag.Name) + } + } + } + + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) + rp.pages.Error503(w) + return + } + + if branchBytes != nil { + var branchResp types.RepoBranchesResponse + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { + for _, branch := range branchResp.Branches { + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) + } + } + } + + user := rp.oauth.GetUser(r) + + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) + if err != nil { + l.Error("failed to fetch email to did mapping", "err", err) + } + + vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) + if err != nil { + l.Error("failed to GetVerifiedObjectCommits", "err", err) + } + + repoInfo := f.RepoInfo(user) + + var shas []string + for _, c := range xrpcResp.Commits { + shas = append(shas, c.Hash.String()) + } + pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) + if err != nil { + l.Error("failed to getPipelineStatuses", "err", err) + // non-fatal + } + + rp.pages.RepoLog(w, pages.RepoLogParams{ + LoggedInUser: user, + TagMap: tagMap, + RepoInfo: repoInfo, + RepoLogResponse: xrpcResp, + EmailToDid: emailToDidMap, + VerifiedCommits: vc, + Pipelines: pipelines, + }) +} + +func (rp *Repo) Commit(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "RepoCommit") + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to fully resolve repo", "err", err) + return + } + ref := chi.URLParam(r, "ref") + ref, _ = url.PathUnescape(ref) + + var diffOpts types.DiffOpts + if d := r.URL.Query().Get("diff"); d == "split" { + diffOpts.Split = true + } + + if !plumbing.IsHash(ref) { + rp.pages.Error404(w) + return + } + + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + host := fmt.Sprintf("%s://%s", scheme, f.Knot) + xrpcc := &indigoxrpc.Client{ + Host: host, + } + + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.diff", "err", xrpcerr) + rp.pages.Error503(w) + return + } + + var result types.RepoCommitResponse + if err := json.Unmarshal(xrpcBytes, &result); err != nil { + l.Error("failed to decode XRPC response", "err", err) + rp.pages.Error503(w) + return + } + + emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) + if err != nil { + l.Error("failed to get email to did mapping", "err", err) + } + + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) + if err != nil { + l.Error("failed to GetVerifiedCommits", "err", err) + } + + user := rp.oauth.GetUser(r) + repoInfo := f.RepoInfo(user) + pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) + if err != nil { + l.Error("failed to getPipelineStatuses", "err", err) + // non-fatal + } + var pipeline *models.Pipeline + if p, ok := pipelines[result.Diff.Commit.This]; ok { + pipeline = &p + } + + rp.pages.RepoCommit(w, pages.RepoCommitParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + RepoCommitResponse: result, + EmailToDid: emailToDidMap, + VerifiedCommit: vc, + Pipeline: pipeline, + DiffOpts: diffOpts, + }) +} diff --git a/appview/repo/opengraph.go b/appview/repo/opengraph.go index 6c6122e4..37871344 100644 --- a/appview/repo/opengraph.go +++ b/appview/repo/opengraph.go @@ -327,7 +327,7 @@ func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDeta return nil } -func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) { +func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { f, err := rp.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) diff --git a/appview/repo/repo.go b/appview/repo/repo.go index 370a89d7..eeafdb79 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -3,47 +3,37 @@ package repo import ( "context" "database/sql" - "encoding/json" "errors" "fmt" - "io" "log/slog" "net/http" "net/url" - "path/filepath" "slices" - "strconv" "strings" "time" "tangled.org/core/api/tangled" - "tangled.org/core/appview/commitverify" "tangled.org/core/appview/config" "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" - "tangled.org/core/appview/pages/markup" "tangled.org/core/appview/reporesolver" "tangled.org/core/appview/validator" xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/eventconsumer" "tangled.org/core/idresolver" - "tangled.org/core/patchutil" "tangled.org/core/rbac" "tangled.org/core/tid" - "tangled.org/core/types" "tangled.org/core/xrpc/serviceauth" comatproto "github.com/bluesky-social/indigo/api/atproto" atpclient "github.com/bluesky-social/indigo/atproto/client" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" - indigoxrpc "github.com/bluesky-social/indigo/xrpc" securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-chi/chi/v5" - "github.com/go-git/go-git/v5/plumbing" ) type Repo struct { @@ -88,748 +78,7 @@ func New( } } -func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "DownloadArchive") - - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - return - } - - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - host := fmt.Sprintf("%s://%s", scheme, f.Knot) - xrpcc := &indigoxrpc.Client{ - Host: host, - } - - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.archive", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - // Set headers for file download, just pass along whatever the knot specifies - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") - filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - w.Header().Set("Content-Type", "application/gzip") - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) - - // Write the archive data directly - w.Write(archiveBytes) -} - -func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "RepoLog") - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to fully resolve repo", "err", err) - return - } - - page := 1 - if r.URL.Query().Get("page") != "" { - page, err = strconv.Atoi(r.URL.Query().Get("page")) - if err != nil { - page = 1 - } - } - - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) - - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - host := fmt.Sprintf("%s://%s", scheme, f.Knot) - xrpcc := &indigoxrpc.Client{ - Host: host, - } - - limit := int64(60) - cursor := "" - if page > 1 { - // Convert page number to cursor (offset) - offset := (page - 1) * int(limit) - cursor = strconv.Itoa(offset) - } - - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) - xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.log", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - var xrpcResp types.RepoLogResponse - if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { - l.Error("failed to decode XRPC response", "err", err) - rp.pages.Error503(w) - return - } - - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - tagMap := make(map[string][]string) - if tagBytes != nil { - var tagResp types.RepoTagsResponse - if err := json.Unmarshal(tagBytes, &tagResp); err == nil { - for _, tag := range tagResp.Tags { - hash := tag.Hash - if tag.Tag != nil { - hash = tag.Tag.Target.String() - } - tagMap[hash] = append(tagMap[hash], tag.Name) - } - } - } - - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - if branchBytes != nil { - var branchResp types.RepoBranchesResponse - if err := json.Unmarshal(branchBytes, &branchResp); err == nil { - for _, branch := range branchResp.Branches { - tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) - } - } - } - - user := rp.oauth.GetUser(r) - - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) - if err != nil { - l.Error("failed to fetch email to did mapping", "err", err) - } - - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) - if err != nil { - l.Error("failed to GetVerifiedObjectCommits", "err", err) - } - - repoInfo := f.RepoInfo(user) - - var shas []string - for _, c := range xrpcResp.Commits { - shas = append(shas, c.Hash.String()) - } - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) - if err != nil { - l.Error("failed to getPipelineStatuses", "err", err) - // non-fatal - } - - rp.pages.RepoLog(w, pages.RepoLogParams{ - LoggedInUser: user, - TagMap: tagMap, - RepoInfo: repoInfo, - RepoLogResponse: xrpcResp, - EmailToDid: emailToDidMap, - VerifiedCommits: vc, - Pipelines: pipelines, - }) -} - -func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "RepoCommit") - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to fully resolve repo", "err", err) - return - } - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) - - var diffOpts types.DiffOpts - if d := r.URL.Query().Get("diff"); d == "split" { - diffOpts.Split = true - } - - if !plumbing.IsHash(ref) { - rp.pages.Error404(w) - return - } - - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - host := fmt.Sprintf("%s://%s", scheme, f.Knot) - xrpcc := &indigoxrpc.Client{ - Host: host, - } - - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) - xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.diff", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - var result types.RepoCommitResponse - if err := json.Unmarshal(xrpcBytes, &result); err != nil { - l.Error("failed to decode XRPC response", "err", err) - rp.pages.Error503(w) - return - } - - emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) - if err != nil { - l.Error("failed to get email to did mapping", "err", err) - } - - vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) - if err != nil { - l.Error("failed to GetVerifiedCommits", "err", err) - } - - user := rp.oauth.GetUser(r) - repoInfo := f.RepoInfo(user) - pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) - if err != nil { - l.Error("failed to getPipelineStatuses", "err", err) - // non-fatal - } - var pipeline *models.Pipeline - if p, ok := pipelines[result.Diff.Commit.This]; ok { - pipeline = &p - } - - rp.pages.RepoCommit(w, pages.RepoCommitParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - RepoCommitResponse: result, - EmailToDid: emailToDidMap, - VerifiedCommit: vc, - Pipeline: pipeline, - DiffOpts: diffOpts, - }) -} - -func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "RepoTree") - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to fully resolve repo", "err", err) - return - } - - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) - - // if the tree path has a trailing slash, let's strip it - // so we don't 404 - treePath := chi.URLParam(r, "*") - treePath, _ = url.PathUnescape(treePath) - treePath = strings.TrimSuffix(treePath, "/") - - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - host := fmt.Sprintf("%s://%s", scheme, f.Knot) - xrpcc := &indigoxrpc.Client{ - Host: host, - } - - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) - xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.tree", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - // Convert XRPC response to internal types.RepoTreeResponse - files := make([]types.NiceTree, len(xrpcResp.Files)) - for i, xrpcFile := range xrpcResp.Files { - file := types.NiceTree{ - Name: xrpcFile.Name, - Mode: xrpcFile.Mode, - Size: int64(xrpcFile.Size), - IsFile: xrpcFile.Is_file, - IsSubtree: xrpcFile.Is_subtree, - } - - // Convert last commit info if present - if xrpcFile.Last_commit != nil { - commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) - file.LastCommit = &types.LastCommitInfo{ - Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), - Message: xrpcFile.Last_commit.Message, - When: commitWhen, - } - } - - files[i] = file - } - - result := types.RepoTreeResponse{ - Ref: xrpcResp.Ref, - Files: files, - } - - if xrpcResp.Parent != nil { - result.Parent = *xrpcResp.Parent - } - if xrpcResp.Dotdot != nil { - result.DotDot = *xrpcResp.Dotdot - } - if xrpcResp.Readme != nil { - result.ReadmeFileName = xrpcResp.Readme.Filename - result.Readme = xrpcResp.Readme.Contents - } - - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, - // so we can safely redirect to the "parent" (which is the same file). - if len(result.Files) == 0 && result.Parent == treePath { - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) - http.Redirect(w, r, redirectTo, http.StatusFound) - return - } - - user := rp.oauth.GetUser(r) - - var breadcrumbs [][]string - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) - if treePath != "" { - for idx, elem := range strings.Split(treePath, "/") { - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) - } - } - - sortFiles(result.Files) - - rp.pages.RepoTree(w, pages.RepoTreeParams{ - LoggedInUser: user, - BreadCrumbs: breadcrumbs, - TreePath: treePath, - RepoInfo: f.RepoInfo(user), - RepoTreeResponse: result, - }) -} - -func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "RepoTags") - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - return - } - - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - host := fmt.Sprintf("%s://%s", scheme, f.Knot) - xrpcc := &indigoxrpc.Client{ - Host: host, - } - - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) - xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - var result types.RepoTagsResponse - if err := json.Unmarshal(xrpcBytes, &result); err != nil { - l.Error("failed to decode XRPC response", "err", err) - rp.pages.Error503(w) - return - } - - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) - if err != nil { - l.Error("failed grab artifacts", "err", err) - return - } - - // convert artifacts to map for easy UI building - artifactMap := make(map[plumbing.Hash][]models.Artifact) - for _, a := range artifacts { - artifactMap[a.Tag] = append(artifactMap[a.Tag], a) - } - - var danglingArtifacts []models.Artifact - for _, a := range artifacts { - found := false - for _, t := range result.Tags { - if t.Tag != nil { - if t.Tag.Hash == a.Tag { - found = true - } - } - } - - if !found { - danglingArtifacts = append(danglingArtifacts, a) - } - } - - user := rp.oauth.GetUser(r) - rp.pages.RepoTags(w, pages.RepoTagsParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - RepoTagsResponse: result, - ArtifactMap: artifactMap, - DanglingArtifacts: danglingArtifacts, - }) -} - -func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "RepoBranches") - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - return - } - - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - host := fmt.Sprintf("%s://%s", scheme, f.Knot) - xrpcc := &indigoxrpc.Client{ - Host: host, - } - - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - var result types.RepoBranchesResponse - if err := json.Unmarshal(xrpcBytes, &result); err != nil { - l.Error("failed to decode XRPC response", "err", err) - rp.pages.Error503(w) - return - } - - sortBranches(result.Branches) - - user := rp.oauth.GetUser(r) - rp.pages.RepoBranches(w, pages.RepoBranchesParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - RepoBranchesResponse: result, - }) -} - -func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "DeleteBranch") - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - return - } - - noticeId := "delete-branch-error" - fail := func(msg string, err error) { - l.Error(msg, "err", err) - rp.pages.Notice(w, noticeId, msg) - } - - branch := r.FormValue("branch") - if branch == "" { - fail("No branch provided.", nil) - return - } - - client, err := rp.oauth.ServiceClient( - r, - oauth.WithService(f.Knot), - oauth.WithLxm(tangled.RepoDeleteBranchNSID), - oauth.WithDev(rp.config.Core.Dev), - ) - if err != nil { - fail("Failed to connect to knotserver", nil) - return - } - - err = tangled.RepoDeleteBranch( - r.Context(), - client, - &tangled.RepoDeleteBranch_Input{ - Branch: branch, - Repo: f.RepoAt().String(), - }, - ) - if err := xrpcclient.HandleXrpcErr(err); err != nil { - fail(fmt.Sprintf("Failed to delete branch: %s", err), err) - return - } - l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) - - rp.pages.HxRefresh(w) -} - -func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "RepoBlob") - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - return - } - - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) - - filePath := chi.URLParam(r, "*") - filePath, _ = url.PathUnescape(filePath) - - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - host := fmt.Sprintf("%s://%s", scheme, f.Knot) - xrpcc := &indigoxrpc.Client{ - Host: host, - } - - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.blob", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - // Use XRPC response directly instead of converting to internal types - - var breadcrumbs [][]string - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) - if filePath != "" { - for idx, elem := range strings.Split(filePath, "/") { - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) - } - } - - showRendered := false - renderToggle := false - - if markup.GetFormat(resp.Path) == markup.FormatMarkdown { - renderToggle = true - showRendered = r.URL.Query().Get("code") != "true" - } - - var unsupported bool - var isImage bool - var isVideo bool - var contentSrc string - - if resp.IsBinary != nil && *resp.IsBinary { - ext := strings.ToLower(filepath.Ext(resp.Path)) - switch ext { - case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": - isImage = true - case ".mp4", ".webm", ".ogg", ".mov", ".avi": - isVideo = true - default: - unsupported = true - } - - // fetch the raw binary content using sh.tangled.repo.blob xrpc - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) - - baseURL := &url.URL{ - Scheme: scheme, - Host: f.Knot, - Path: "/xrpc/sh.tangled.repo.blob", - } - query := baseURL.Query() - query.Set("repo", repoName) - query.Set("ref", ref) - query.Set("path", filePath) - query.Set("raw", "true") - baseURL.RawQuery = query.Encode() - blobURL := baseURL.String() - - contentSrc = blobURL - if !rp.config.Core.Dev { - contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) - } - } - - lines := 0 - if resp.IsBinary == nil || !*resp.IsBinary { - lines = strings.Count(resp.Content, "\n") + 1 - } - - var sizeHint uint64 - if resp.Size != nil { - sizeHint = uint64(*resp.Size) - } else { - sizeHint = uint64(len(resp.Content)) - } - - user := rp.oauth.GetUser(r) - - // Determine if content is binary (dereference pointer) - isBinary := false - if resp.IsBinary != nil { - isBinary = *resp.IsBinary - } - - rp.pages.RepoBlob(w, pages.RepoBlobParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - BreadCrumbs: breadcrumbs, - ShowRendered: showRendered, - RenderToggle: renderToggle, - Unsupported: unsupported, - IsImage: isImage, - IsVideo: isVideo, - ContentSrc: contentSrc, - RepoBlob_Output: resp, - Contents: resp.Content, - Lines: lines, - SizeHint: sizeHint, - IsBinary: isBinary, - }) -} - -func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "RepoBlobRaw") - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - w.WriteHeader(http.StatusBadRequest) - return - } - - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) - - filePath := chi.URLParam(r, "*") - filePath, _ = url.PathUnescape(filePath) - - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) - baseURL := &url.URL{ - Scheme: scheme, - Host: f.Knot, - Path: "/xrpc/sh.tangled.repo.blob", - } - query := baseURL.Query() - query.Set("repo", repo) - query.Set("ref", ref) - query.Set("path", filePath) - query.Set("raw", "true") - baseURL.RawQuery = query.Encode() - blobURL := baseURL.String() - - req, err := http.NewRequest("GET", blobURL, nil) - if err != nil { - l.Error("failed to create request", "err", err) - return - } - - // forward the If-None-Match header - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { - req.Header.Set("If-None-Match", clientETag) - } - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - l.Error("failed to reach knotserver", "err", err) - rp.pages.Error503(w) - return - } - defer resp.Body.Close() - - // forward 304 not modified - if resp.StatusCode == http.StatusNotModified { - w.WriteHeader(http.StatusNotModified) - return - } - - if resp.StatusCode != http.StatusOK { - l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) - w.WriteHeader(resp.StatusCode) - _, _ = io.Copy(w, resp.Body) - return - } - - contentType := resp.Header.Get("Content-Type") - body, err := io.ReadAll(resp.Body) - if err != nil { - l.Error("error reading response body from knotserver", "err", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { - // serve all textual content as text/plain - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Write(body) - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { - // serve images and videos with their original content type - w.Header().Set("Content-Type", contentType) - w.Write(body) - } else { - w.WriteHeader(http.StatusUnsupportedMediaType) - w.Write([]byte("unsupported content type")) - return - } -} - // isTextualMimeType returns true if the MIME type represents textual content -// that should be served as text/plain -func isTextualMimeType(mimeType string) bool { - textualTypes := []string{ - "application/json", - "application/xml", - "application/yaml", - "application/x-yaml", - "application/toml", - "application/javascript", - "application/ecmascript", - "message/", - } - - return slices.Contains(textualTypes, mimeType) -} // modify the spindle configured for this repo func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { @@ -1548,563 +797,142 @@ func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { Rkey: rkey, SubjectDid: collaboratorIdent.DID, RepoAt: f.RepoAt(), - Created: createdAt, - }) - if err != nil { - fail("Failed to add collaborator.", err) - return - } - - err = tx.Commit() - if err != nil { - fail("Failed to add collaborator.", err) - return - } - - err = rp.enforcer.E.SavePolicy() - if err != nil { - fail("Failed to update collaborator permissions.", err) - return - } - - // clear aturi to when everything is successful - aturi = "" - - rp.pages.HxRefresh(w) -} - -func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { - user := rp.oauth.GetUser(r) - l := rp.logger.With("handler", "DeleteRepo") - - noticeId := "operation-error" - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - return - } - - // remove record from pds - atpClient, err := rp.oauth.AuthorizedClient(r) - if err != nil { - l.Error("failed to get authorized client", "err", err) - return - } - _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ - Collection: tangled.RepoNSID, - Repo: user.Did, - Rkey: f.Rkey, - }) - if err != nil { - l.Error("failed to delete record", "err", err) - rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") - return - } - l.Info("removed repo record", "aturi", f.RepoAt().String()) - - client, err := rp.oauth.ServiceClient( - r, - oauth.WithService(f.Knot), - oauth.WithLxm(tangled.RepoDeleteNSID), - oauth.WithDev(rp.config.Core.Dev), - ) - if err != nil { - l.Error("failed to connect to knot server", "err", err) - return - } - - err = tangled.RepoDelete( - r.Context(), - client, - &tangled.RepoDelete_Input{ - Did: f.OwnerDid(), - Name: f.Name, - Rkey: f.Rkey, - }, - ) - if err := xrpcclient.HandleXrpcErr(err); err != nil { - rp.pages.Notice(w, noticeId, err.Error()) - return - } - l.Info("deleted repo from knot") - - tx, err := rp.db.BeginTx(r.Context(), nil) - if err != nil { - l.Error("failed to start tx") - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) - return - } - defer func() { - tx.Rollback() - err = rp.enforcer.E.LoadPolicy() - if err != nil { - l.Error("failed to rollback policies") - } - }() - - // remove collaborator RBAC - repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) - if err != nil { - rp.pages.Notice(w, noticeId, "Failed to remove collaborators") - return - } - for _, c := range repoCollaborators { - did := c[0] - rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) - } - l.Info("removed collaborators") - - // remove repo RBAC - err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) - if err != nil { - rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") - return - } - - // remove repo from db - err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) - if err != nil { - rp.pages.Notice(w, noticeId, "Failed to update appview") - return - } - l.Info("removed repo from db") - - err = tx.Commit() - if err != nil { - l.Error("failed to commit changes", "err", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - err = rp.enforcer.E.SavePolicy() - if err != nil { - l.Error("failed to update ACLs", "err", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) -} - -func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "EditBaseSettings") - - noticeId := "repo-base-settings-error" - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - w.WriteHeader(http.StatusBadRequest) - return - } - - client, err := rp.oauth.AuthorizedClient(r) - if err != nil { - l.Error("failed to get client") - rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.") - return - } - - var ( - description = r.FormValue("description") - website = r.FormValue("website") - topicStr = r.FormValue("topics") - ) - - err = rp.validator.ValidateURI(website) - if err != nil { - l.Error("invalid uri", "err", err) - rp.pages.Notice(w, noticeId, err.Error()) - return - } - - topics, err := rp.validator.ValidateRepoTopicStr(topicStr) - if err != nil { - l.Error("invalid topics", "err", err) - rp.pages.Notice(w, noticeId, err.Error()) - return - } - l.Debug("got", "topicsStr", topicStr, "topics", topics) - - newRepo := f.Repo - newRepo.Description = description - newRepo.Website = website - newRepo.Topics = topics - record := newRepo.AsRecord() - - tx, err := rp.db.BeginTx(r.Context(), nil) - if err != nil { - l.Error("failed to begin transaction", "err", err) - rp.pages.Notice(w, noticeId, "Failed to save repository information.") - return - } - defer tx.Rollback() - - err = db.PutRepo(tx, newRepo) - if err != nil { - l.Error("failed to update repository", "err", err) - rp.pages.Notice(w, noticeId, "Failed to save repository information.") - return - } - - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) - if err != nil { - // failed to get record - l.Error("failed to get repo record", "err", err) - rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.") - return - } - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoNSID, - Repo: newRepo.Did, - Rkey: newRepo.Rkey, - SwapRecord: ex.Cid, - Record: &lexutil.LexiconTypeDecoder{ - Val: &record, - }, + Created: createdAt, }) - if err != nil { - l.Error("failed to perferom update-repo query", "err", err) - // failed to get record - rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.") + fail("Failed to add collaborator.", err) return } err = tx.Commit() if err != nil { - l.Error("failed to commit", "err", err) + fail("Failed to add collaborator.", err) + return + } + + err = rp.enforcer.E.SavePolicy() + if err != nil { + fail("Failed to update collaborator permissions.", err) + return } + // clear aturi to when everything is successful + aturi = "" + rp.pages.HxRefresh(w) } -func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "SetDefaultBranch") +func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { + user := rp.oauth.GetUser(r) + l := rp.logger.With("handler", "DeleteRepo") + noticeId := "operation-error" f, err := rp.repoResolver.Resolve(r) if err != nil { l.Error("failed to get repo and knot", "err", err) return } - noticeId := "operation-error" - branch := r.FormValue("branch") - if branch == "" { - http.Error(w, "malformed form", http.StatusBadRequest) + // remove record from pds + atpClient, err := rp.oauth.AuthorizedClient(r) + if err != nil { + l.Error("failed to get authorized client", "err", err) + return + } + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ + Collection: tangled.RepoNSID, + Repo: user.Did, + Rkey: f.Rkey, + }) + if err != nil { + l.Error("failed to delete record", "err", err) + rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") return } + l.Info("removed repo record", "aturi", f.RepoAt().String()) client, err := rp.oauth.ServiceClient( r, oauth.WithService(f.Knot), - oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), + oauth.WithLxm(tangled.RepoDeleteNSID), oauth.WithDev(rp.config.Core.Dev), ) if err != nil { l.Error("failed to connect to knot server", "err", err) - rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") return } - xe := tangled.RepoSetDefaultBranch( + err = tangled.RepoDelete( r.Context(), client, - &tangled.RepoSetDefaultBranch_Input{ - Repo: f.RepoAt().String(), - DefaultBranch: branch, + &tangled.RepoDelete_Input{ + Did: f.OwnerDid(), + Name: f.Name, + Rkey: f.Rkey, }, ) - if err := xrpcclient.HandleXrpcErr(xe); err != nil { - l.Error("xrpc failed", "err", xe) + if err := xrpcclient.HandleXrpcErr(err); err != nil { rp.pages.Notice(w, noticeId, err.Error()) return } + l.Info("deleted repo from knot") - rp.pages.HxRefresh(w) -} - -func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { - user := rp.oauth.GetUser(r) - l := rp.logger.With("handler", "Secrets") - l = l.With("did", user.Did) - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - return - } - - if f.Spindle == "" { - l.Error("empty spindle cannot add/rm secret", "err", err) - return - } - - lxm := tangled.RepoAddSecretNSID - if r.Method == http.MethodDelete { - lxm = tangled.RepoRemoveSecretNSID - } - - spindleClient, err := rp.oauth.ServiceClient( - r, - oauth.WithService(f.Spindle), - oauth.WithLxm(lxm), - oauth.WithExp(60), - oauth.WithDev(rp.config.Core.Dev), - ) + tx, err := rp.db.BeginTx(r.Context(), nil) if err != nil { - l.Error("failed to create spindle client", "err", err) - return - } - - key := r.FormValue("key") - if key == "" { - w.WriteHeader(http.StatusBadRequest) + l.Error("failed to start tx") + w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) return } - - switch r.Method { - case http.MethodPut: - errorId := "add-secret-error" - - value := r.FormValue("value") - if value == "" { - w.WriteHeader(http.StatusBadRequest) - return - } - - err = tangled.RepoAddSecret( - r.Context(), - spindleClient, - &tangled.RepoAddSecret_Input{ - Repo: f.RepoAt().String(), - Key: key, - Value: value, - }, - ) - if err != nil { - l.Error("Failed to add secret.", "err", err) - rp.pages.Notice(w, errorId, "Failed to add secret.") - return - } - - case http.MethodDelete: - errorId := "operation-error" - - err = tangled.RepoRemoveSecret( - r.Context(), - spindleClient, - &tangled.RepoRemoveSecret_Input{ - Repo: f.RepoAt().String(), - Key: key, - }, - ) + defer func() { + tx.Rollback() + err = rp.enforcer.E.LoadPolicy() if err != nil { - l.Error("Failed to delete secret.", "err", err) - rp.pages.Notice(w, errorId, "Failed to delete secret.") - return + l.Error("failed to rollback policies") } - } - - rp.pages.HxRefresh(w) -} - -type tab = map[string]any - -var ( - // would be great to have ordered maps right about now - settingsTabs []tab = []tab{ - {"Name": "general", "Icon": "sliders-horizontal"}, - {"Name": "access", "Icon": "users"}, - {"Name": "pipelines", "Icon": "layers-2"}, - } -) - -func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { - tabVal := r.URL.Query().Get("tab") - if tabVal == "" { - tabVal = "general" - } - - switch tabVal { - case "general": - rp.generalSettings(w, r) - - case "access": - rp.accessSettings(w, r) - - case "pipelines": - rp.pipelineSettings(w, r) - } -} - -func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "generalSettings") - - f, err := rp.repoResolver.Resolve(r) - user := rp.oauth.GetUser(r) - - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - host := fmt.Sprintf("%s://%s", scheme, f.Knot) - xrpcc := &indigoxrpc.Client{ - Host: host, - } + }() - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) - rp.pages.Error503(w) + // remove collaborator RBAC + repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) + if err != nil { + rp.pages.Notice(w, noticeId, "Failed to remove collaborators") return } - - var result types.RepoBranchesResponse - if err := json.Unmarshal(xrpcBytes, &result); err != nil { - l.Error("failed to decode XRPC response", "err", err) - rp.pages.Error503(w) - return + for _, c := range repoCollaborators { + did := c[0] + rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) } + l.Info("removed collaborators") - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) + // remove repo RBAC + err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) if err != nil { - l.Error("failed to fetch labels", "err", err) - rp.pages.Error503(w) + rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") return } - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) + // remove repo from db + err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) if err != nil { - l.Error("failed to fetch labels", "err", err) - rp.pages.Error503(w) + rp.pages.Notice(w, noticeId, "Failed to update appview") return } - // remove default labels from the labels list, if present - defaultLabelMap := make(map[string]bool) - for _, dl := range defaultLabels { - defaultLabelMap[dl.AtUri().String()] = true - } - n := 0 - for _, l := range labels { - if !defaultLabelMap[l.AtUri().String()] { - labels[n] = l - n++ - } - } - labels = labels[:n] - - subscribedLabels := make(map[string]struct{}) - for _, l := range f.Repo.Labels { - subscribedLabels[l] = struct{}{} - } - - // if there is atleast 1 unsubbed default label, show the "subscribe all" button, - // if all default labels are subbed, show the "unsubscribe all" button - shouldSubscribeAll := false - for _, dl := range defaultLabels { - if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { - // one of the default labels is not subscribed to - shouldSubscribeAll = true - break - } - } - - rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - Branches: result.Branches, - Labels: labels, - DefaultLabels: defaultLabels, - SubscribedLabels: subscribedLabels, - ShouldSubscribeAll: shouldSubscribeAll, - Tabs: settingsTabs, - Tab: "general", - }) -} - -func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "accessSettings") - - f, err := rp.repoResolver.Resolve(r) - user := rp.oauth.GetUser(r) + l.Info("removed repo from db") - repoCollaborators, err := f.Collaborators(r.Context()) + err = tx.Commit() if err != nil { - l.Error("failed to get collaborators", "err", err) + l.Error("failed to commit changes", "err", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - Tabs: settingsTabs, - Tab: "access", - Collaborators: repoCollaborators, - }) -} - -func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "pipelineSettings") - - f, err := rp.repoResolver.Resolve(r) - user := rp.oauth.GetUser(r) - - // all spindles that the repo owner is a member of - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) + err = rp.enforcer.E.SavePolicy() if err != nil { - l.Error("failed to fetch spindles", "err", err) + l.Error("failed to update ACLs", "err", err) + http.Error(w, err.Error(), http.StatusInternalServerError) return } - var secrets []*tangled.RepoListSecrets_Secret - if f.Spindle != "" { - if spindleClient, err := rp.oauth.ServiceClient( - r, - oauth.WithService(f.Spindle), - oauth.WithLxm(tangled.RepoListSecretsNSID), - oauth.WithExp(60), - oauth.WithDev(rp.config.Core.Dev), - ); err != nil { - l.Error("failed to create spindle client", "err", err) - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { - l.Error("failed to fetch secrets", "err", err) - } else { - secrets = resp.Secrets - } - } - - slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { - return strings.Compare(a.Key, b.Key) - }) - - var dids []string - for _, s := range secrets { - dids = append(dids, s.CreatedBy) - } - resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) - - // convert to a more manageable form - var niceSecret []map[string]any - for id, s := range secrets { - when, _ := time.Parse(time.RFC3339, s.CreatedAt) - niceSecret = append(niceSecret, map[string]any{ - "Id": id, - "Key": s.Key, - "CreatedAt": when, - "CreatedBy": resolvedIdents[id].Handle.String(), - }) - } - - rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - Tabs: settingsTabs, - Tab: "pipelines", - Spindles: spindles, - CurrentSpindle: f.Spindle, - Secrets: niceSecret, - }) + rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) } func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { @@ -2388,199 +1216,3 @@ func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClie }) return err } - -func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "RepoCompareNew") - - user := rp.oauth.GetUser(r) - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - return - } - - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - host := fmt.Sprintf("%s://%s", scheme, f.Knot) - xrpcc := &indigoxrpc.Client{ - Host: host, - } - - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - var branchResult types.RepoBranchesResponse - if err := json.Unmarshal(branchBytes, &branchResult); err != nil { - l.Error("failed to decode XRPC branches response", "err", err) - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") - return - } - branches := branchResult.Branches - - sortBranches(branches) - - var defaultBranch string - for _, b := range branches { - if b.IsDefault { - defaultBranch = b.Name - } - } - - base := defaultBranch - head := defaultBranch - - params := r.URL.Query() - queryBase := params.Get("base") - queryHead := params.Get("head") - if queryBase != "" { - base = queryBase - } - if queryHead != "" { - head = queryHead - } - - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - var tags types.RepoTagsResponse - if err := json.Unmarshal(tagBytes, &tags); err != nil { - l.Error("failed to decode XRPC tags response", "err", err) - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") - return - } - - repoinfo := f.RepoInfo(user) - - rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ - LoggedInUser: user, - RepoInfo: repoinfo, - Branches: branches, - Tags: tags.Tags, - Base: base, - Head: head, - }) -} - -func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "RepoCompare") - - user := rp.oauth.GetUser(r) - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - return - } - - var diffOpts types.DiffOpts - if d := r.URL.Query().Get("diff"); d == "split" { - diffOpts.Split = true - } - - // if user is navigating to one of - // /compare/{base}/{head} - // /compare/{base}...{head} - base := chi.URLParam(r, "base") - head := chi.URLParam(r, "head") - if base == "" && head == "" { - rest := chi.URLParam(r, "*") // master...feature/xyz - parts := strings.SplitN(rest, "...", 2) - if len(parts) == 2 { - base = parts[0] - head = parts[1] - } - } - - base, _ = url.PathUnescape(base) - head, _ = url.PathUnescape(head) - - if base == "" || head == "" { - l.Error("invalid comparison") - rp.pages.Error404(w) - return - } - - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - host := fmt.Sprintf("%s://%s", scheme, f.Knot) - xrpcc := &indigoxrpc.Client{ - Host: host, - } - - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) - - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - var branches types.RepoBranchesResponse - if err := json.Unmarshal(branchBytes, &branches); err != nil { - l.Error("failed to decode XRPC branches response", "err", err) - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") - return - } - - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - var tags types.RepoTagsResponse - if err := json.Unmarshal(tagBytes, &tags); err != nil { - l.Error("failed to decode XRPC tags response", "err", err) - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") - return - } - - compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.compare", "err", xrpcerr) - rp.pages.Error503(w) - return - } - - var formatPatch types.RepoFormatPatchResponse - if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { - l.Error("failed to decode XRPC compare response", "err", err) - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") - return - } - - var diff types.NiceDiff - if formatPatch.CombinedPatchRaw != "" { - diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) - } else { - diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) - } - - repoinfo := f.RepoInfo(user) - - rp.pages.RepoCompare(w, pages.RepoCompareParams{ - LoggedInUser: user, - RepoInfo: repoinfo, - Branches: branches.Branches, - Tags: tags.Tags, - Base: base, - Head: head, - Diff: &diff, - DiffOpts: diffOpts, - }) - -} diff --git a/appview/repo/router.go b/appview/repo/router.go index 70fffa63..b5def882 100644 --- a/appview/repo/router.go +++ b/appview/repo/router.go @@ -9,19 +9,19 @@ import ( func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { r := chi.NewRouter() - r.Get("/", rp.RepoIndex) - r.Get("/opengraph", rp.RepoOpenGraphSummary) - r.Get("/feed.atom", rp.RepoAtomFeed) - r.Get("/commits/{ref}", rp.RepoLog) + r.Get("/", rp.Index) + r.Get("/opengraph", rp.Opengraph) + r.Get("/feed.atom", rp.AtomFeed) + r.Get("/commits/{ref}", rp.Log) r.Route("/tree/{ref}", func(r chi.Router) { - r.Get("/", rp.RepoIndex) - r.Get("/*", rp.RepoTree) + r.Get("/", rp.Index) + r.Get("/*", rp.Tree) }) - r.Get("/commit/{ref}", rp.RepoCommit) - r.Get("/branches", rp.RepoBranches) + r.Get("/commit/{ref}", rp.Commit) + r.Get("/branches", rp.Branches) r.Delete("/branches", rp.DeleteBranch) r.Route("/tags", func(r chi.Router) { - r.Get("/", rp.RepoTags) + r.Get("/", rp.Tags) r.Route("/{tag}", func(r chi.Router) { r.Get("/download/{file}", rp.DownloadArtifact) @@ -37,7 +37,7 @@ func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { }) }) }) - r.Get("/blob/{ref}/*", rp.RepoBlob) + r.Get("/blob/{ref}/*", rp.Blob) r.Get("/raw/{ref}/*", rp.RepoBlobRaw) // intentionally doesn't use /* as this isn't @@ -54,15 +54,15 @@ func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { }) r.Route("/compare", func(r chi.Router) { - r.Get("/", rp.RepoCompareNew) // start an new comparison + r.Get("/", rp.CompareNew) // start an new comparison // we have to wildcard here since we want to support GitHub's compare syntax // /compare/{ref1}...{ref2} // for example: // /compare/master...some/feature // /compare/master...example.com:another/feature <- this is a fork - r.Get("/{base}/{head}", rp.RepoCompare) - r.Get("/*", rp.RepoCompare) + r.Get("/{base}/{head}", rp.Compare) + r.Get("/*", rp.Compare) }) // label panel in issues/pulls/discussions/tasks @@ -75,7 +75,7 @@ func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(rp.oauth)) r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { - r.Get("/", rp.RepoSettings) + r.Get("/", rp.Settings) r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings) r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef) diff --git a/appview/repo/settings.go b/appview/repo/settings.go new file mode 100644 index 00000000..b0ed53f5 --- /dev/null +++ b/appview/repo/settings.go @@ -0,0 +1,442 @@ +package repo + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/appview/oauth" + "tangled.org/core/appview/pages" + xrpcclient "tangled.org/core/appview/xrpcclient" + "tangled.org/core/types" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + lexutil "github.com/bluesky-social/indigo/lex/util" + indigoxrpc "github.com/bluesky-social/indigo/xrpc" +) + +type tab = map[string]any + +var ( + // would be great to have ordered maps right about now + settingsTabs []tab = []tab{ + {"Name": "general", "Icon": "sliders-horizontal"}, + {"Name": "access", "Icon": "users"}, + {"Name": "pipelines", "Icon": "layers-2"}, + } +) + +func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "SetDefaultBranch") + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + + noticeId := "operation-error" + branch := r.FormValue("branch") + if branch == "" { + http.Error(w, "malformed form", http.StatusBadRequest) + return + } + + client, err := rp.oauth.ServiceClient( + r, + oauth.WithService(f.Knot), + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), + oauth.WithDev(rp.config.Core.Dev), + ) + if err != nil { + l.Error("failed to connect to knot server", "err", err) + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") + return + } + + xe := tangled.RepoSetDefaultBranch( + r.Context(), + client, + &tangled.RepoSetDefaultBranch_Input{ + Repo: f.RepoAt().String(), + DefaultBranch: branch, + }, + ) + if err := xrpcclient.HandleXrpcErr(xe); err != nil { + l.Error("xrpc failed", "err", xe) + rp.pages.Notice(w, noticeId, err.Error()) + return + } + + rp.pages.HxRefresh(w) +} + +func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { + user := rp.oauth.GetUser(r) + l := rp.logger.With("handler", "Secrets") + l = l.With("did", user.Did) + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + + if f.Spindle == "" { + l.Error("empty spindle cannot add/rm secret", "err", err) + return + } + + lxm := tangled.RepoAddSecretNSID + if r.Method == http.MethodDelete { + lxm = tangled.RepoRemoveSecretNSID + } + + spindleClient, err := rp.oauth.ServiceClient( + r, + oauth.WithService(f.Spindle), + oauth.WithLxm(lxm), + oauth.WithExp(60), + oauth.WithDev(rp.config.Core.Dev), + ) + if err != nil { + l.Error("failed to create spindle client", "err", err) + return + } + + key := r.FormValue("key") + if key == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodPut: + errorId := "add-secret-error" + + value := r.FormValue("value") + if value == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + err = tangled.RepoAddSecret( + r.Context(), + spindleClient, + &tangled.RepoAddSecret_Input{ + Repo: f.RepoAt().String(), + Key: key, + Value: value, + }, + ) + if err != nil { + l.Error("Failed to add secret.", "err", err) + rp.pages.Notice(w, errorId, "Failed to add secret.") + return + } + + case http.MethodDelete: + errorId := "operation-error" + + err = tangled.RepoRemoveSecret( + r.Context(), + spindleClient, + &tangled.RepoRemoveSecret_Input{ + Repo: f.RepoAt().String(), + Key: key, + }, + ) + if err != nil { + l.Error("Failed to delete secret.", "err", err) + rp.pages.Notice(w, errorId, "Failed to delete secret.") + return + } + } + + rp.pages.HxRefresh(w) +} + +func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) { + tabVal := r.URL.Query().Get("tab") + if tabVal == "" { + tabVal = "general" + } + + switch tabVal { + case "general": + rp.generalSettings(w, r) + + case "access": + rp.accessSettings(w, r) + + case "pipelines": + rp.pipelineSettings(w, r) + } +} + +func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "generalSettings") + + f, err := rp.repoResolver.Resolve(r) + user := rp.oauth.GetUser(r) + + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + host := fmt.Sprintf("%s://%s", scheme, f.Knot) + xrpcc := &indigoxrpc.Client{ + Host: host, + } + + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) + rp.pages.Error503(w) + return + } + + var result types.RepoBranchesResponse + if err := json.Unmarshal(xrpcBytes, &result); err != nil { + l.Error("failed to decode XRPC response", "err", err) + rp.pages.Error503(w) + return + } + + defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) + if err != nil { + l.Error("failed to fetch labels", "err", err) + rp.pages.Error503(w) + return + } + + labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) + if err != nil { + l.Error("failed to fetch labels", "err", err) + rp.pages.Error503(w) + return + } + // remove default labels from the labels list, if present + defaultLabelMap := make(map[string]bool) + for _, dl := range defaultLabels { + defaultLabelMap[dl.AtUri().String()] = true + } + n := 0 + for _, l := range labels { + if !defaultLabelMap[l.AtUri().String()] { + labels[n] = l + n++ + } + } + labels = labels[:n] + + subscribedLabels := make(map[string]struct{}) + for _, l := range f.Repo.Labels { + subscribedLabels[l] = struct{}{} + } + + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, + // if all default labels are subbed, show the "unsubscribe all" button + shouldSubscribeAll := false + for _, dl := range defaultLabels { + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { + // one of the default labels is not subscribed to + shouldSubscribeAll = true + break + } + } + + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Branches: result.Branches, + Labels: labels, + DefaultLabels: defaultLabels, + SubscribedLabels: subscribedLabels, + ShouldSubscribeAll: shouldSubscribeAll, + Tabs: settingsTabs, + Tab: "general", + }) +} + +func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "accessSettings") + + f, err := rp.repoResolver.Resolve(r) + user := rp.oauth.GetUser(r) + + repoCollaborators, err := f.Collaborators(r.Context()) + if err != nil { + l.Error("failed to get collaborators", "err", err) + } + + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Tabs: settingsTabs, + Tab: "access", + Collaborators: repoCollaborators, + }) +} + +func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "pipelineSettings") + + f, err := rp.repoResolver.Resolve(r) + user := rp.oauth.GetUser(r) + + // all spindles that the repo owner is a member of + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) + if err != nil { + l.Error("failed to fetch spindles", "err", err) + return + } + + var secrets []*tangled.RepoListSecrets_Secret + if f.Spindle != "" { + if spindleClient, err := rp.oauth.ServiceClient( + r, + oauth.WithService(f.Spindle), + oauth.WithLxm(tangled.RepoListSecretsNSID), + oauth.WithExp(60), + oauth.WithDev(rp.config.Core.Dev), + ); err != nil { + l.Error("failed to create spindle client", "err", err) + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { + l.Error("failed to fetch secrets", "err", err) + } else { + secrets = resp.Secrets + } + } + + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { + return strings.Compare(a.Key, b.Key) + }) + + var dids []string + for _, s := range secrets { + dids = append(dids, s.CreatedBy) + } + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) + + // convert to a more manageable form + var niceSecret []map[string]any + for id, s := range secrets { + when, _ := time.Parse(time.RFC3339, s.CreatedAt) + niceSecret = append(niceSecret, map[string]any{ + "Id": id, + "Key": s.Key, + "CreatedAt": when, + "CreatedBy": resolvedIdents[id].Handle.String(), + }) + } + + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Tabs: settingsTabs, + Tab: "pipelines", + Spindles: spindles, + CurrentSpindle: f.Spindle, + Secrets: niceSecret, + }) +} + +func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "EditBaseSettings") + + noticeId := "repo-base-settings-error" + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + client, err := rp.oauth.AuthorizedClient(r) + if err != nil { + l.Error("failed to get client") + rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.") + return + } + + var ( + description = r.FormValue("description") + website = r.FormValue("website") + topicStr = r.FormValue("topics") + ) + + err = rp.validator.ValidateURI(website) + if err != nil { + l.Error("invalid uri", "err", err) + rp.pages.Notice(w, noticeId, err.Error()) + return + } + + topics, err := rp.validator.ValidateRepoTopicStr(topicStr) + if err != nil { + l.Error("invalid topics", "err", err) + rp.pages.Notice(w, noticeId, err.Error()) + return + } + l.Debug("got", "topicsStr", topicStr, "topics", topics) + + newRepo := f.Repo + newRepo.Description = description + newRepo.Website = website + newRepo.Topics = topics + record := newRepo.AsRecord() + + tx, err := rp.db.BeginTx(r.Context(), nil) + if err != nil { + l.Error("failed to begin transaction", "err", err) + rp.pages.Notice(w, noticeId, "Failed to save repository information.") + return + } + defer tx.Rollback() + + err = db.PutRepo(tx, newRepo) + if err != nil { + l.Error("failed to update repository", "err", err) + rp.pages.Notice(w, noticeId, "Failed to save repository information.") + return + } + + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) + if err != nil { + // failed to get record + l.Error("failed to get repo record", "err", err) + rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.") + return + } + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoNSID, + Repo: newRepo.Did, + Rkey: newRepo.Rkey, + SwapRecord: ex.Cid, + Record: &lexutil.LexiconTypeDecoder{ + Val: &record, + }, + }) + + if err != nil { + l.Error("failed to perferom update-repo query", "err", err) + // failed to get record + rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.") + return + } + + err = tx.Commit() + if err != nil { + l.Error("failed to commit", "err", err) + } + + rp.pages.HxRefresh(w) +} diff --git a/appview/repo/tags.go b/appview/repo/tags.go new file mode 100644 index 00000000..320ac93f --- /dev/null +++ b/appview/repo/tags.go @@ -0,0 +1,79 @@ +package repo + +import ( + "encoding/json" + "fmt" + "net/http" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pages" + xrpcclient "tangled.org/core/appview/xrpcclient" + "tangled.org/core/types" + + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "github.com/go-git/go-git/v5/plumbing" +) + +func (rp *Repo) Tags(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "RepoTags") + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + host := fmt.Sprintf("%s://%s", scheme, f.Knot) + xrpcc := &indigoxrpc.Client{ + Host: host, + } + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) + rp.pages.Error503(w) + return + } + var result types.RepoTagsResponse + if err := json.Unmarshal(xrpcBytes, &result); err != nil { + l.Error("failed to decode XRPC response", "err", err) + rp.pages.Error503(w) + return + } + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) + if err != nil { + l.Error("failed grab artifacts", "err", err) + return + } + // convert artifacts to map for easy UI building + artifactMap := make(map[plumbing.Hash][]models.Artifact) + for _, a := range artifacts { + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) + } + var danglingArtifacts []models.Artifact + for _, a := range artifacts { + found := false + for _, t := range result.Tags { + if t.Tag != nil { + if t.Tag.Hash == a.Tag { + found = true + } + } + } + if !found { + danglingArtifacts = append(danglingArtifacts, a) + } + } + user := rp.oauth.GetUser(r) + rp.pages.RepoTags(w, pages.RepoTagsParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + RepoTagsResponse: result, + ArtifactMap: artifactMap, + DanglingArtifacts: danglingArtifacts, + }) +} diff --git a/appview/repo/tree.go b/appview/repo/tree.go new file mode 100644 index 00000000..6ad6b98d --- /dev/null +++ b/appview/repo/tree.go @@ -0,0 +1,107 @@ +package repo + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/pages" + xrpcclient "tangled.org/core/appview/xrpcclient" + "tangled.org/core/types" + + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "github.com/go-chi/chi/v5" + "github.com/go-git/go-git/v5/plumbing" +) + +func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "RepoTree") + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to fully resolve repo", "err", err) + return + } + ref := chi.URLParam(r, "ref") + ref, _ = url.PathUnescape(ref) + // if the tree path has a trailing slash, let's strip it + // so we don't 404 + treePath := chi.URLParam(r, "*") + treePath, _ = url.PathUnescape(treePath) + treePath = strings.TrimSuffix(treePath, "/") + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + host := fmt.Sprintf("%s://%s", scheme, f.Knot) + xrpcc := &indigoxrpc.Client{ + Host: host, + } + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.tree", "err", xrpcerr) + rp.pages.Error503(w) + return + } + // Convert XRPC response to internal types.RepoTreeResponse + files := make([]types.NiceTree, len(xrpcResp.Files)) + for i, xrpcFile := range xrpcResp.Files { + file := types.NiceTree{ + Name: xrpcFile.Name, + Mode: xrpcFile.Mode, + Size: int64(xrpcFile.Size), + IsFile: xrpcFile.Is_file, + IsSubtree: xrpcFile.Is_subtree, + } + // Convert last commit info if present + if xrpcFile.Last_commit != nil { + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) + file.LastCommit = &types.LastCommitInfo{ + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), + Message: xrpcFile.Last_commit.Message, + When: commitWhen, + } + } + files[i] = file + } + result := types.RepoTreeResponse{ + Ref: xrpcResp.Ref, + Files: files, + } + if xrpcResp.Parent != nil { + result.Parent = *xrpcResp.Parent + } + if xrpcResp.Dotdot != nil { + result.DotDot = *xrpcResp.Dotdot + } + if xrpcResp.Readme != nil { + result.ReadmeFileName = xrpcResp.Readme.Filename + result.Readme = xrpcResp.Readme.Contents + } + // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, + // so we can safely redirect to the "parent" (which is the same file). + if len(result.Files) == 0 && result.Parent == treePath { + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) + http.Redirect(w, r, redirectTo, http.StatusFound) + return + } + user := rp.oauth.GetUser(r) + var breadcrumbs [][]string + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) + if treePath != "" { + for idx, elem := range strings.Split(treePath, "/") { + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) + } + } + sortFiles(result.Files) + rp.pages.RepoTree(w, pages.RepoTreeParams{ + LoggedInUser: user, + BreadCrumbs: breadcrumbs, + TreePath: treePath, + RepoInfo: f.RepoInfo(user), + RepoTreeResponse: result, + }) +} -- 2.43.0