Monorepo for Tangled tangled.org

appview/repo: split up handlers into separate files

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li 1d290c44 926b6451

verified
+49
appview/repo/archive.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "net/url" 7 + "strings" 8 + 9 + "tangled.org/core/api/tangled" 10 + xrpcclient "tangled.org/core/appview/xrpcclient" 11 + 12 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 + "github.com/go-chi/chi/v5" 14 + "github.com/go-git/go-git/v5/plumbing" 15 + ) 16 + 17 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 18 + l := rp.logger.With("handler", "DownloadArchive") 19 + ref := chi.URLParam(r, "ref") 20 + ref, _ = url.PathUnescape(ref) 21 + f, err := rp.repoResolver.Resolve(r) 22 + if err != nil { 23 + l.Error("failed to get repo and knot", "err", err) 24 + return 25 + } 26 + scheme := "http" 27 + if !rp.config.Core.Dev { 28 + scheme = "https" 29 + } 30 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 31 + xrpcc := &indigoxrpc.Client{ 32 + Host: host, 33 + } 34 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 35 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 36 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 38 + rp.pages.Error503(w) 39 + return 40 + } 41 + // Set headers for file download, just pass along whatever the knot specifies 42 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 43 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 44 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 45 + w.Header().Set("Content-Type", "application/gzip") 46 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 47 + // Write the archive data directly 48 + w.Write(archiveBytes) 49 + }
+219
appview/repo/blob.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "net/http" 7 + "net/url" 8 + "path/filepath" 9 + "slices" 10 + "strings" 11 + 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pages/markup" 15 + xrpcclient "tangled.org/core/appview/xrpcclient" 16 + 17 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 18 + "github.com/go-chi/chi/v5" 19 + ) 20 + 21 + func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 22 + l := rp.logger.With("handler", "RepoBlob") 23 + f, err := rp.repoResolver.Resolve(r) 24 + if err != nil { 25 + l.Error("failed to get repo and knot", "err", err) 26 + return 27 + } 28 + ref := chi.URLParam(r, "ref") 29 + ref, _ = url.PathUnescape(ref) 30 + filePath := chi.URLParam(r, "*") 31 + filePath, _ = url.PathUnescape(filePath) 32 + scheme := "http" 33 + if !rp.config.Core.Dev { 34 + scheme = "https" 35 + } 36 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 37 + xrpcc := &indigoxrpc.Client{ 38 + Host: host, 39 + } 40 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 41 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 42 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 43 + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 44 + rp.pages.Error503(w) 45 + return 46 + } 47 + // Use XRPC response directly instead of converting to internal types 48 + var breadcrumbs [][]string 49 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 50 + if filePath != "" { 51 + for idx, elem := range strings.Split(filePath, "/") { 52 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 53 + } 54 + } 55 + showRendered := false 56 + renderToggle := false 57 + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 58 + renderToggle = true 59 + showRendered = r.URL.Query().Get("code") != "true" 60 + } 61 + var unsupported bool 62 + var isImage bool 63 + var isVideo bool 64 + var contentSrc string 65 + if resp.IsBinary != nil && *resp.IsBinary { 66 + ext := strings.ToLower(filepath.Ext(resp.Path)) 67 + switch ext { 68 + case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 69 + isImage = true 70 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 71 + isVideo = true 72 + default: 73 + unsupported = true 74 + } 75 + // fetch the raw binary content using sh.tangled.repo.blob xrpc 76 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 77 + baseURL := &url.URL{ 78 + Scheme: scheme, 79 + Host: f.Knot, 80 + Path: "/xrpc/sh.tangled.repo.blob", 81 + } 82 + query := baseURL.Query() 83 + query.Set("repo", repoName) 84 + query.Set("ref", ref) 85 + query.Set("path", filePath) 86 + query.Set("raw", "true") 87 + baseURL.RawQuery = query.Encode() 88 + blobURL := baseURL.String() 89 + contentSrc = blobURL 90 + if !rp.config.Core.Dev { 91 + contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 92 + } 93 + } 94 + lines := 0 95 + if resp.IsBinary == nil || !*resp.IsBinary { 96 + lines = strings.Count(resp.Content, "\n") + 1 97 + } 98 + var sizeHint uint64 99 + if resp.Size != nil { 100 + sizeHint = uint64(*resp.Size) 101 + } else { 102 + sizeHint = uint64(len(resp.Content)) 103 + } 104 + user := rp.oauth.GetUser(r) 105 + // Determine if content is binary (dereference pointer) 106 + isBinary := false 107 + if resp.IsBinary != nil { 108 + isBinary = *resp.IsBinary 109 + } 110 + rp.pages.RepoBlob(w, pages.RepoBlobParams{ 111 + LoggedInUser: user, 112 + RepoInfo: f.RepoInfo(user), 113 + BreadCrumbs: breadcrumbs, 114 + ShowRendered: showRendered, 115 + RenderToggle: renderToggle, 116 + Unsupported: unsupported, 117 + IsImage: isImage, 118 + IsVideo: isVideo, 119 + ContentSrc: contentSrc, 120 + RepoBlob_Output: resp, 121 + Contents: resp.Content, 122 + Lines: lines, 123 + SizeHint: sizeHint, 124 + IsBinary: isBinary, 125 + }) 126 + } 127 + 128 + func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 129 + l := rp.logger.With("handler", "RepoBlobRaw") 130 + f, err := rp.repoResolver.Resolve(r) 131 + if err != nil { 132 + l.Error("failed to get repo and knot", "err", err) 133 + w.WriteHeader(http.StatusBadRequest) 134 + return 135 + } 136 + ref := chi.URLParam(r, "ref") 137 + ref, _ = url.PathUnescape(ref) 138 + filePath := chi.URLParam(r, "*") 139 + filePath, _ = url.PathUnescape(filePath) 140 + scheme := "http" 141 + if !rp.config.Core.Dev { 142 + scheme = "https" 143 + } 144 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 145 + baseURL := &url.URL{ 146 + Scheme: scheme, 147 + Host: f.Knot, 148 + Path: "/xrpc/sh.tangled.repo.blob", 149 + } 150 + query := baseURL.Query() 151 + query.Set("repo", repo) 152 + query.Set("ref", ref) 153 + query.Set("path", filePath) 154 + query.Set("raw", "true") 155 + baseURL.RawQuery = query.Encode() 156 + blobURL := baseURL.String() 157 + req, err := http.NewRequest("GET", blobURL, nil) 158 + if err != nil { 159 + l.Error("failed to create request", "err", err) 160 + return 161 + } 162 + // forward the If-None-Match header 163 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 164 + req.Header.Set("If-None-Match", clientETag) 165 + } 166 + client := &http.Client{} 167 + resp, err := client.Do(req) 168 + if err != nil { 169 + l.Error("failed to reach knotserver", "err", err) 170 + rp.pages.Error503(w) 171 + return 172 + } 173 + defer resp.Body.Close() 174 + // forward 304 not modified 175 + if resp.StatusCode == http.StatusNotModified { 176 + w.WriteHeader(http.StatusNotModified) 177 + return 178 + } 179 + if resp.StatusCode != http.StatusOK { 180 + l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 181 + w.WriteHeader(resp.StatusCode) 182 + _, _ = io.Copy(w, resp.Body) 183 + return 184 + } 185 + contentType := resp.Header.Get("Content-Type") 186 + body, err := io.ReadAll(resp.Body) 187 + if err != nil { 188 + l.Error("error reading response body from knotserver", "err", err) 189 + w.WriteHeader(http.StatusInternalServerError) 190 + return 191 + } 192 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 193 + // serve all textual content as text/plain 194 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 195 + w.Write(body) 196 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 197 + // serve images and videos with their original content type 198 + w.Header().Set("Content-Type", contentType) 199 + w.Write(body) 200 + } else { 201 + w.WriteHeader(http.StatusUnsupportedMediaType) 202 + w.Write([]byte("unsupported content type")) 203 + return 204 + } 205 + } 206 + 207 + func isTextualMimeType(mimeType string) bool { 208 + textualTypes := []string{ 209 + "application/json", 210 + "application/xml", 211 + "application/yaml", 212 + "application/x-yaml", 213 + "application/toml", 214 + "application/javascript", 215 + "application/ecmascript", 216 + "message/", 217 + } 218 + return slices.Contains(textualTypes, mimeType) 219 + }
+95
appview/repo/branches.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/appview/oauth" 10 + "tangled.org/core/appview/pages" 11 + xrpcclient "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/types" 13 + 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 + ) 16 + 17 + func (rp *Repo) Branches(w http.ResponseWriter, r *http.Request) { 18 + l := rp.logger.With("handler", "RepoBranches") 19 + f, err := rp.repoResolver.Resolve(r) 20 + if err != nil { 21 + l.Error("failed to get repo and knot", "err", err) 22 + return 23 + } 24 + scheme := "http" 25 + if !rp.config.Core.Dev { 26 + scheme = "https" 27 + } 28 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 29 + xrpcc := &indigoxrpc.Client{ 30 + Host: host, 31 + } 32 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 33 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 34 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 35 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 36 + rp.pages.Error503(w) 37 + return 38 + } 39 + var result types.RepoBranchesResponse 40 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 41 + l.Error("failed to decode XRPC response", "err", err) 42 + rp.pages.Error503(w) 43 + return 44 + } 45 + sortBranches(result.Branches) 46 + user := rp.oauth.GetUser(r) 47 + rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 48 + LoggedInUser: user, 49 + RepoInfo: f.RepoInfo(user), 50 + RepoBranchesResponse: result, 51 + }) 52 + } 53 + 54 + func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 55 + l := rp.logger.With("handler", "DeleteBranch") 56 + f, err := rp.repoResolver.Resolve(r) 57 + if err != nil { 58 + l.Error("failed to get repo and knot", "err", err) 59 + return 60 + } 61 + noticeId := "delete-branch-error" 62 + fail := func(msg string, err error) { 63 + l.Error(msg, "err", err) 64 + rp.pages.Notice(w, noticeId, msg) 65 + } 66 + branch := r.FormValue("branch") 67 + if branch == "" { 68 + fail("No branch provided.", nil) 69 + return 70 + } 71 + client, err := rp.oauth.ServiceClient( 72 + r, 73 + oauth.WithService(f.Knot), 74 + oauth.WithLxm(tangled.RepoDeleteBranchNSID), 75 + oauth.WithDev(rp.config.Core.Dev), 76 + ) 77 + if err != nil { 78 + fail("Failed to connect to knotserver", nil) 79 + return 80 + } 81 + err = tangled.RepoDeleteBranch( 82 + r.Context(), 83 + client, 84 + &tangled.RepoDeleteBranch_Input{ 85 + Branch: branch, 86 + Repo: f.RepoAt().String(), 87 + }, 88 + ) 89 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 90 + fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 91 + return 92 + } 93 + l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 94 + rp.pages.HxRefresh(w) 95 + }
+214
appview/repo/compare.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "strings" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/pages" 12 + xrpcclient "tangled.org/core/appview/xrpcclient" 13 + "tangled.org/core/patchutil" 14 + "tangled.org/core/types" 15 + 16 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 + "github.com/go-chi/chi/v5" 18 + ) 19 + 20 + func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) { 21 + l := rp.logger.With("handler", "RepoCompareNew") 22 + 23 + user := rp.oauth.GetUser(r) 24 + f, err := rp.repoResolver.Resolve(r) 25 + if err != nil { 26 + l.Error("failed to get repo and knot", "err", err) 27 + return 28 + } 29 + 30 + scheme := "http" 31 + if !rp.config.Core.Dev { 32 + scheme = "https" 33 + } 34 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 35 + xrpcc := &indigoxrpc.Client{ 36 + Host: host, 37 + } 38 + 39 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 40 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 41 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 42 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 43 + rp.pages.Error503(w) 44 + return 45 + } 46 + 47 + var branchResult types.RepoBranchesResponse 48 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 49 + l.Error("failed to decode XRPC branches response", "err", err) 50 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 51 + return 52 + } 53 + branches := branchResult.Branches 54 + 55 + sortBranches(branches) 56 + 57 + var defaultBranch string 58 + for _, b := range branches { 59 + if b.IsDefault { 60 + defaultBranch = b.Name 61 + } 62 + } 63 + 64 + base := defaultBranch 65 + head := defaultBranch 66 + 67 + params := r.URL.Query() 68 + queryBase := params.Get("base") 69 + queryHead := params.Get("head") 70 + if queryBase != "" { 71 + base = queryBase 72 + } 73 + if queryHead != "" { 74 + head = queryHead 75 + } 76 + 77 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 78 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 79 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 80 + rp.pages.Error503(w) 81 + return 82 + } 83 + 84 + var tags types.RepoTagsResponse 85 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 86 + l.Error("failed to decode XRPC tags response", "err", err) 87 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 88 + return 89 + } 90 + 91 + repoinfo := f.RepoInfo(user) 92 + 93 + rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 94 + LoggedInUser: user, 95 + RepoInfo: repoinfo, 96 + Branches: branches, 97 + Tags: tags.Tags, 98 + Base: base, 99 + Head: head, 100 + }) 101 + } 102 + 103 + func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) { 104 + l := rp.logger.With("handler", "RepoCompare") 105 + 106 + user := rp.oauth.GetUser(r) 107 + f, err := rp.repoResolver.Resolve(r) 108 + if err != nil { 109 + l.Error("failed to get repo and knot", "err", err) 110 + return 111 + } 112 + 113 + var diffOpts types.DiffOpts 114 + if d := r.URL.Query().Get("diff"); d == "split" { 115 + diffOpts.Split = true 116 + } 117 + 118 + // if user is navigating to one of 119 + // /compare/{base}/{head} 120 + // /compare/{base}...{head} 121 + base := chi.URLParam(r, "base") 122 + head := chi.URLParam(r, "head") 123 + if base == "" && head == "" { 124 + rest := chi.URLParam(r, "*") // master...feature/xyz 125 + parts := strings.SplitN(rest, "...", 2) 126 + if len(parts) == 2 { 127 + base = parts[0] 128 + head = parts[1] 129 + } 130 + } 131 + 132 + base, _ = url.PathUnescape(base) 133 + head, _ = url.PathUnescape(head) 134 + 135 + if base == "" || head == "" { 136 + l.Error("invalid comparison") 137 + rp.pages.Error404(w) 138 + return 139 + } 140 + 141 + scheme := "http" 142 + if !rp.config.Core.Dev { 143 + scheme = "https" 144 + } 145 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 146 + xrpcc := &indigoxrpc.Client{ 147 + Host: host, 148 + } 149 + 150 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 151 + 152 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 153 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 154 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 155 + rp.pages.Error503(w) 156 + return 157 + } 158 + 159 + var branches types.RepoBranchesResponse 160 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 161 + l.Error("failed to decode XRPC branches response", "err", err) 162 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 163 + return 164 + } 165 + 166 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 167 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 168 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 169 + rp.pages.Error503(w) 170 + return 171 + } 172 + 173 + var tags types.RepoTagsResponse 174 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 175 + l.Error("failed to decode XRPC tags response", "err", err) 176 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 177 + return 178 + } 179 + 180 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 181 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 182 + l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 183 + rp.pages.Error503(w) 184 + return 185 + } 186 + 187 + var formatPatch types.RepoFormatPatchResponse 188 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 189 + l.Error("failed to decode XRPC compare response", "err", err) 190 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 191 + return 192 + } 193 + 194 + var diff types.NiceDiff 195 + if formatPatch.CombinedPatchRaw != "" { 196 + diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 197 + } else { 198 + diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 199 + } 200 + 201 + repoinfo := f.RepoInfo(user) 202 + 203 + rp.pages.RepoCompare(w, pages.RepoCompareParams{ 204 + LoggedInUser: user, 205 + RepoInfo: repoinfo, 206 + Branches: branches.Branches, 207 + Tags: tags.Tags, 208 + Base: base, 209 + Head: head, 210 + Diff: &diff, 211 + DiffOpts: diffOpts, 212 + }) 213 + 214 + }
+1 -1
appview/repo/feed.go
··· 146 return fmt.Sprintf("%s in %s", base, repoName) 147 } 148 149 - func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 150 f, err := rp.repoResolver.Resolve(r) 151 if err != nil { 152 log.Println("failed to fully resolve repo:", err)
··· 146 return fmt.Sprintf("%s in %s", base, repoName) 147 } 148 149 + func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) { 150 f, err := rp.repoResolver.Resolve(r) 151 if err != nil { 152 log.Println("failed to fully resolve repo:", err)
+1 -1
appview/repo/index.go
··· 30 "github.com/go-enry/go-enry/v2" 31 ) 32 33 - func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 34 l := rp.logger.With("handler", "RepoIndex") 35 36 ref := chi.URLParam(r, "ref")
··· 30 "github.com/go-enry/go-enry/v2" 31 ) 32 33 + func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) { 34 l := rp.logger.With("handler", "RepoIndex") 35 36 ref := chi.URLParam(r, "ref")
+223
appview/repo/log.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "strconv" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/commitverify" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/pages" 15 + xrpcclient "tangled.org/core/appview/xrpcclient" 16 + "tangled.org/core/types" 17 + 18 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 + "github.com/go-chi/chi/v5" 20 + "github.com/go-git/go-git/v5/plumbing" 21 + ) 22 + 23 + func (rp *Repo) Log(w http.ResponseWriter, r *http.Request) { 24 + l := rp.logger.With("handler", "RepoLog") 25 + 26 + f, err := rp.repoResolver.Resolve(r) 27 + if err != nil { 28 + l.Error("failed to fully resolve repo", "err", err) 29 + return 30 + } 31 + 32 + page := 1 33 + if r.URL.Query().Get("page") != "" { 34 + page, err = strconv.Atoi(r.URL.Query().Get("page")) 35 + if err != nil { 36 + page = 1 37 + } 38 + } 39 + 40 + ref := chi.URLParam(r, "ref") 41 + ref, _ = url.PathUnescape(ref) 42 + 43 + scheme := "http" 44 + if !rp.config.Core.Dev { 45 + scheme = "https" 46 + } 47 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 48 + xrpcc := &indigoxrpc.Client{ 49 + Host: host, 50 + } 51 + 52 + limit := int64(60) 53 + cursor := "" 54 + if page > 1 { 55 + // Convert page number to cursor (offset) 56 + offset := (page - 1) * int(limit) 57 + cursor = strconv.Itoa(offset) 58 + } 59 + 60 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 61 + xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 62 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 63 + l.Error("failed to call XRPC repo.log", "err", xrpcerr) 64 + rp.pages.Error503(w) 65 + return 66 + } 67 + 68 + var xrpcResp types.RepoLogResponse 69 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 70 + l.Error("failed to decode XRPC response", "err", err) 71 + rp.pages.Error503(w) 72 + return 73 + } 74 + 75 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 76 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 77 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 78 + rp.pages.Error503(w) 79 + return 80 + } 81 + 82 + tagMap := make(map[string][]string) 83 + if tagBytes != nil { 84 + var tagResp types.RepoTagsResponse 85 + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 86 + for _, tag := range tagResp.Tags { 87 + hash := tag.Hash 88 + if tag.Tag != nil { 89 + hash = tag.Tag.Target.String() 90 + } 91 + tagMap[hash] = append(tagMap[hash], tag.Name) 92 + } 93 + } 94 + } 95 + 96 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 97 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 98 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 99 + rp.pages.Error503(w) 100 + return 101 + } 102 + 103 + if branchBytes != nil { 104 + var branchResp types.RepoBranchesResponse 105 + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 106 + for _, branch := range branchResp.Branches { 107 + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 108 + } 109 + } 110 + } 111 + 112 + user := rp.oauth.GetUser(r) 113 + 114 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 115 + if err != nil { 116 + l.Error("failed to fetch email to did mapping", "err", err) 117 + } 118 + 119 + vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 120 + if err != nil { 121 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 122 + } 123 + 124 + repoInfo := f.RepoInfo(user) 125 + 126 + var shas []string 127 + for _, c := range xrpcResp.Commits { 128 + shas = append(shas, c.Hash.String()) 129 + } 130 + pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 131 + if err != nil { 132 + l.Error("failed to getPipelineStatuses", "err", err) 133 + // non-fatal 134 + } 135 + 136 + rp.pages.RepoLog(w, pages.RepoLogParams{ 137 + LoggedInUser: user, 138 + TagMap: tagMap, 139 + RepoInfo: repoInfo, 140 + RepoLogResponse: xrpcResp, 141 + EmailToDid: emailToDidMap, 142 + VerifiedCommits: vc, 143 + Pipelines: pipelines, 144 + }) 145 + } 146 + 147 + func (rp *Repo) Commit(w http.ResponseWriter, r *http.Request) { 148 + l := rp.logger.With("handler", "RepoCommit") 149 + 150 + f, err := rp.repoResolver.Resolve(r) 151 + if err != nil { 152 + l.Error("failed to fully resolve repo", "err", err) 153 + return 154 + } 155 + ref := chi.URLParam(r, "ref") 156 + ref, _ = url.PathUnescape(ref) 157 + 158 + var diffOpts types.DiffOpts 159 + if d := r.URL.Query().Get("diff"); d == "split" { 160 + diffOpts.Split = true 161 + } 162 + 163 + if !plumbing.IsHash(ref) { 164 + rp.pages.Error404(w) 165 + return 166 + } 167 + 168 + scheme := "http" 169 + if !rp.config.Core.Dev { 170 + scheme = "https" 171 + } 172 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 173 + xrpcc := &indigoxrpc.Client{ 174 + Host: host, 175 + } 176 + 177 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 178 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 179 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 180 + l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 181 + rp.pages.Error503(w) 182 + return 183 + } 184 + 185 + var result types.RepoCommitResponse 186 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 187 + l.Error("failed to decode XRPC response", "err", err) 188 + rp.pages.Error503(w) 189 + return 190 + } 191 + 192 + emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 193 + if err != nil { 194 + l.Error("failed to get email to did mapping", "err", err) 195 + } 196 + 197 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 198 + if err != nil { 199 + l.Error("failed to GetVerifiedCommits", "err", err) 200 + } 201 + 202 + user := rp.oauth.GetUser(r) 203 + repoInfo := f.RepoInfo(user) 204 + pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 205 + if err != nil { 206 + l.Error("failed to getPipelineStatuses", "err", err) 207 + // non-fatal 208 + } 209 + var pipeline *models.Pipeline 210 + if p, ok := pipelines[result.Diff.Commit.This]; ok { 211 + pipeline = &p 212 + } 213 + 214 + rp.pages.RepoCommit(w, pages.RepoCommitParams{ 215 + LoggedInUser: user, 216 + RepoInfo: f.RepoInfo(user), 217 + RepoCommitResponse: result, 218 + EmailToDid: emailToDidMap, 219 + VerifiedCommit: vc, 220 + Pipeline: pipeline, 221 + DiffOpts: diffOpts, 222 + }) 223 + }
+1 -1
appview/repo/opengraph.go
··· 327 return nil 328 } 329 330 - func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 331 f, err := rp.repoResolver.Resolve(r) 332 if err != nil { 333 log.Println("failed to get repo and knot", err)
··· 327 return nil 328 } 329 330 + func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { 331 f, err := rp.repoResolver.Resolve(r) 332 if err != nil { 333 log.Println("failed to get repo and knot", err)
-1368
appview/repo/repo.go
··· 3 import ( 4 "context" 5 "database/sql" 6 - "encoding/json" 7 "errors" 8 "fmt" 9 - "io" 10 "log/slog" 11 "net/http" 12 "net/url" 13 - "path/filepath" 14 "slices" 15 - "strconv" 16 "strings" 17 "time" 18 19 "tangled.org/core/api/tangled" 20 - "tangled.org/core/appview/commitverify" 21 "tangled.org/core/appview/config" 22 "tangled.org/core/appview/db" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/notify" 25 "tangled.org/core/appview/oauth" 26 "tangled.org/core/appview/pages" 27 - "tangled.org/core/appview/pages/markup" 28 "tangled.org/core/appview/reporesolver" 29 "tangled.org/core/appview/validator" 30 xrpcclient "tangled.org/core/appview/xrpcclient" 31 "tangled.org/core/eventconsumer" 32 "tangled.org/core/idresolver" 33 - "tangled.org/core/patchutil" 34 "tangled.org/core/rbac" 35 "tangled.org/core/tid" 36 - "tangled.org/core/types" 37 "tangled.org/core/xrpc/serviceauth" 38 39 comatproto "github.com/bluesky-social/indigo/api/atproto" 40 atpclient "github.com/bluesky-social/indigo/atproto/client" 41 "github.com/bluesky-social/indigo/atproto/syntax" 42 lexutil "github.com/bluesky-social/indigo/lex/util" 43 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 44 securejoin "github.com/cyphar/filepath-securejoin" 45 "github.com/go-chi/chi/v5" 46 - "github.com/go-git/go-git/v5/plumbing" 47 ) 48 49 type Repo struct { ··· 88 } 89 } 90 91 - func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 92 - l := rp.logger.With("handler", "DownloadArchive") 93 - 94 - ref := chi.URLParam(r, "ref") 95 - ref, _ = url.PathUnescape(ref) 96 - 97 - f, err := rp.repoResolver.Resolve(r) 98 - if err != nil { 99 - l.Error("failed to get repo and knot", "err", err) 100 - return 101 - } 102 - 103 - scheme := "http" 104 - if !rp.config.Core.Dev { 105 - scheme = "https" 106 - } 107 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 108 - xrpcc := &indigoxrpc.Client{ 109 - Host: host, 110 - } 111 - 112 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 113 - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 114 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 115 - l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 116 - rp.pages.Error503(w) 117 - return 118 - } 119 - 120 - // Set headers for file download, just pass along whatever the knot specifies 121 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 122 - filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 123 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 124 - w.Header().Set("Content-Type", "application/gzip") 125 - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 126 - 127 - // Write the archive data directly 128 - w.Write(archiveBytes) 129 - } 130 - 131 - func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 132 - l := rp.logger.With("handler", "RepoLog") 133 - 134 - f, err := rp.repoResolver.Resolve(r) 135 - if err != nil { 136 - l.Error("failed to fully resolve repo", "err", err) 137 - return 138 - } 139 - 140 - page := 1 141 - if r.URL.Query().Get("page") != "" { 142 - page, err = strconv.Atoi(r.URL.Query().Get("page")) 143 - if err != nil { 144 - page = 1 145 - } 146 - } 147 - 148 - ref := chi.URLParam(r, "ref") 149 - ref, _ = url.PathUnescape(ref) 150 - 151 - scheme := "http" 152 - if !rp.config.Core.Dev { 153 - scheme = "https" 154 - } 155 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 156 - xrpcc := &indigoxrpc.Client{ 157 - Host: host, 158 - } 159 - 160 - limit := int64(60) 161 - cursor := "" 162 - if page > 1 { 163 - // Convert page number to cursor (offset) 164 - offset := (page - 1) * int(limit) 165 - cursor = strconv.Itoa(offset) 166 - } 167 - 168 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 169 - xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 170 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 171 - l.Error("failed to call XRPC repo.log", "err", xrpcerr) 172 - rp.pages.Error503(w) 173 - return 174 - } 175 - 176 - var xrpcResp types.RepoLogResponse 177 - if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 178 - l.Error("failed to decode XRPC response", "err", err) 179 - rp.pages.Error503(w) 180 - return 181 - } 182 - 183 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 184 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 185 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 186 - rp.pages.Error503(w) 187 - return 188 - } 189 - 190 - tagMap := make(map[string][]string) 191 - if tagBytes != nil { 192 - var tagResp types.RepoTagsResponse 193 - if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 194 - for _, tag := range tagResp.Tags { 195 - hash := tag.Hash 196 - if tag.Tag != nil { 197 - hash = tag.Tag.Target.String() 198 - } 199 - tagMap[hash] = append(tagMap[hash], tag.Name) 200 - } 201 - } 202 - } 203 - 204 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 205 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 206 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 207 - rp.pages.Error503(w) 208 - return 209 - } 210 - 211 - if branchBytes != nil { 212 - var branchResp types.RepoBranchesResponse 213 - if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 214 - for _, branch := range branchResp.Branches { 215 - tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 216 - } 217 - } 218 - } 219 - 220 - user := rp.oauth.GetUser(r) 221 - 222 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 223 - if err != nil { 224 - l.Error("failed to fetch email to did mapping", "err", err) 225 - } 226 - 227 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 228 - if err != nil { 229 - l.Error("failed to GetVerifiedObjectCommits", "err", err) 230 - } 231 - 232 - repoInfo := f.RepoInfo(user) 233 - 234 - var shas []string 235 - for _, c := range xrpcResp.Commits { 236 - shas = append(shas, c.Hash.String()) 237 - } 238 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 239 - if err != nil { 240 - l.Error("failed to getPipelineStatuses", "err", err) 241 - // non-fatal 242 - } 243 - 244 - rp.pages.RepoLog(w, pages.RepoLogParams{ 245 - LoggedInUser: user, 246 - TagMap: tagMap, 247 - RepoInfo: repoInfo, 248 - RepoLogResponse: xrpcResp, 249 - EmailToDid: emailToDidMap, 250 - VerifiedCommits: vc, 251 - Pipelines: pipelines, 252 - }) 253 - } 254 - 255 - func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 256 - l := rp.logger.With("handler", "RepoCommit") 257 - 258 - f, err := rp.repoResolver.Resolve(r) 259 - if err != nil { 260 - l.Error("failed to fully resolve repo", "err", err) 261 - return 262 - } 263 - ref := chi.URLParam(r, "ref") 264 - ref, _ = url.PathUnescape(ref) 265 - 266 - var diffOpts types.DiffOpts 267 - if d := r.URL.Query().Get("diff"); d == "split" { 268 - diffOpts.Split = true 269 - } 270 - 271 - if !plumbing.IsHash(ref) { 272 - rp.pages.Error404(w) 273 - return 274 - } 275 - 276 - scheme := "http" 277 - if !rp.config.Core.Dev { 278 - scheme = "https" 279 - } 280 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 281 - xrpcc := &indigoxrpc.Client{ 282 - Host: host, 283 - } 284 - 285 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 286 - xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 287 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 288 - l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 289 - rp.pages.Error503(w) 290 - return 291 - } 292 - 293 - var result types.RepoCommitResponse 294 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 295 - l.Error("failed to decode XRPC response", "err", err) 296 - rp.pages.Error503(w) 297 - return 298 - } 299 - 300 - emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 301 - if err != nil { 302 - l.Error("failed to get email to did mapping", "err", err) 303 - } 304 - 305 - vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 306 - if err != nil { 307 - l.Error("failed to GetVerifiedCommits", "err", err) 308 - } 309 - 310 - user := rp.oauth.GetUser(r) 311 - repoInfo := f.RepoInfo(user) 312 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 313 - if err != nil { 314 - l.Error("failed to getPipelineStatuses", "err", err) 315 - // non-fatal 316 - } 317 - var pipeline *models.Pipeline 318 - if p, ok := pipelines[result.Diff.Commit.This]; ok { 319 - pipeline = &p 320 - } 321 - 322 - rp.pages.RepoCommit(w, pages.RepoCommitParams{ 323 - LoggedInUser: user, 324 - RepoInfo: f.RepoInfo(user), 325 - RepoCommitResponse: result, 326 - EmailToDid: emailToDidMap, 327 - VerifiedCommit: vc, 328 - Pipeline: pipeline, 329 - DiffOpts: diffOpts, 330 - }) 331 - } 332 - 333 - func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 334 - l := rp.logger.With("handler", "RepoTree") 335 - 336 - f, err := rp.repoResolver.Resolve(r) 337 - if err != nil { 338 - l.Error("failed to fully resolve repo", "err", err) 339 - return 340 - } 341 - 342 - ref := chi.URLParam(r, "ref") 343 - ref, _ = url.PathUnescape(ref) 344 - 345 - // if the tree path has a trailing slash, let's strip it 346 - // so we don't 404 347 - treePath := chi.URLParam(r, "*") 348 - treePath, _ = url.PathUnescape(treePath) 349 - treePath = strings.TrimSuffix(treePath, "/") 350 - 351 - scheme := "http" 352 - if !rp.config.Core.Dev { 353 - scheme = "https" 354 - } 355 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 356 - xrpcc := &indigoxrpc.Client{ 357 - Host: host, 358 - } 359 - 360 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 361 - xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 362 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 363 - l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 364 - rp.pages.Error503(w) 365 - return 366 - } 367 - 368 - // Convert XRPC response to internal types.RepoTreeResponse 369 - files := make([]types.NiceTree, len(xrpcResp.Files)) 370 - for i, xrpcFile := range xrpcResp.Files { 371 - file := types.NiceTree{ 372 - Name: xrpcFile.Name, 373 - Mode: xrpcFile.Mode, 374 - Size: int64(xrpcFile.Size), 375 - IsFile: xrpcFile.Is_file, 376 - IsSubtree: xrpcFile.Is_subtree, 377 - } 378 - 379 - // Convert last commit info if present 380 - if xrpcFile.Last_commit != nil { 381 - commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 382 - file.LastCommit = &types.LastCommitInfo{ 383 - Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 384 - Message: xrpcFile.Last_commit.Message, 385 - When: commitWhen, 386 - } 387 - } 388 - 389 - files[i] = file 390 - } 391 - 392 - result := types.RepoTreeResponse{ 393 - Ref: xrpcResp.Ref, 394 - Files: files, 395 - } 396 - 397 - if xrpcResp.Parent != nil { 398 - result.Parent = *xrpcResp.Parent 399 - } 400 - if xrpcResp.Dotdot != nil { 401 - result.DotDot = *xrpcResp.Dotdot 402 - } 403 - if xrpcResp.Readme != nil { 404 - result.ReadmeFileName = xrpcResp.Readme.Filename 405 - result.Readme = xrpcResp.Readme.Contents 406 - } 407 - 408 - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 409 - // so we can safely redirect to the "parent" (which is the same file). 410 - if len(result.Files) == 0 && result.Parent == treePath { 411 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 412 - http.Redirect(w, r, redirectTo, http.StatusFound) 413 - return 414 - } 415 - 416 - user := rp.oauth.GetUser(r) 417 - 418 - var breadcrumbs [][]string 419 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 420 - if treePath != "" { 421 - for idx, elem := range strings.Split(treePath, "/") { 422 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 423 - } 424 - } 425 - 426 - sortFiles(result.Files) 427 - 428 - rp.pages.RepoTree(w, pages.RepoTreeParams{ 429 - LoggedInUser: user, 430 - BreadCrumbs: breadcrumbs, 431 - TreePath: treePath, 432 - RepoInfo: f.RepoInfo(user), 433 - RepoTreeResponse: result, 434 - }) 435 - } 436 - 437 - func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 438 - l := rp.logger.With("handler", "RepoTags") 439 - 440 - f, err := rp.repoResolver.Resolve(r) 441 - if err != nil { 442 - l.Error("failed to get repo and knot", "err", err) 443 - return 444 - } 445 - 446 - scheme := "http" 447 - if !rp.config.Core.Dev { 448 - scheme = "https" 449 - } 450 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 451 - xrpcc := &indigoxrpc.Client{ 452 - Host: host, 453 - } 454 - 455 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 456 - xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 457 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 458 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 459 - rp.pages.Error503(w) 460 - return 461 - } 462 - 463 - var result types.RepoTagsResponse 464 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 465 - l.Error("failed to decode XRPC response", "err", err) 466 - rp.pages.Error503(w) 467 - return 468 - } 469 - 470 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 471 - if err != nil { 472 - l.Error("failed grab artifacts", "err", err) 473 - return 474 - } 475 - 476 - // convert artifacts to map for easy UI building 477 - artifactMap := make(map[plumbing.Hash][]models.Artifact) 478 - for _, a := range artifacts { 479 - artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 480 - } 481 - 482 - var danglingArtifacts []models.Artifact 483 - for _, a := range artifacts { 484 - found := false 485 - for _, t := range result.Tags { 486 - if t.Tag != nil { 487 - if t.Tag.Hash == a.Tag { 488 - found = true 489 - } 490 - } 491 - } 492 - 493 - if !found { 494 - danglingArtifacts = append(danglingArtifacts, a) 495 - } 496 - } 497 - 498 - user := rp.oauth.GetUser(r) 499 - rp.pages.RepoTags(w, pages.RepoTagsParams{ 500 - LoggedInUser: user, 501 - RepoInfo: f.RepoInfo(user), 502 - RepoTagsResponse: result, 503 - ArtifactMap: artifactMap, 504 - DanglingArtifacts: danglingArtifacts, 505 - }) 506 - } 507 - 508 - func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 509 - l := rp.logger.With("handler", "RepoBranches") 510 - 511 - f, err := rp.repoResolver.Resolve(r) 512 - if err != nil { 513 - l.Error("failed to get repo and knot", "err", err) 514 - return 515 - } 516 - 517 - scheme := "http" 518 - if !rp.config.Core.Dev { 519 - scheme = "https" 520 - } 521 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 522 - xrpcc := &indigoxrpc.Client{ 523 - Host: host, 524 - } 525 - 526 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 527 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 528 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 529 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 530 - rp.pages.Error503(w) 531 - return 532 - } 533 - 534 - var result types.RepoBranchesResponse 535 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 536 - l.Error("failed to decode XRPC response", "err", err) 537 - rp.pages.Error503(w) 538 - return 539 - } 540 - 541 - sortBranches(result.Branches) 542 - 543 - user := rp.oauth.GetUser(r) 544 - rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 545 - LoggedInUser: user, 546 - RepoInfo: f.RepoInfo(user), 547 - RepoBranchesResponse: result, 548 - }) 549 - } 550 - 551 - func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 552 - l := rp.logger.With("handler", "DeleteBranch") 553 - 554 - f, err := rp.repoResolver.Resolve(r) 555 - if err != nil { 556 - l.Error("failed to get repo and knot", "err", err) 557 - return 558 - } 559 - 560 - noticeId := "delete-branch-error" 561 - fail := func(msg string, err error) { 562 - l.Error(msg, "err", err) 563 - rp.pages.Notice(w, noticeId, msg) 564 - } 565 - 566 - branch := r.FormValue("branch") 567 - if branch == "" { 568 - fail("No branch provided.", nil) 569 - return 570 - } 571 - 572 - client, err := rp.oauth.ServiceClient( 573 - r, 574 - oauth.WithService(f.Knot), 575 - oauth.WithLxm(tangled.RepoDeleteBranchNSID), 576 - oauth.WithDev(rp.config.Core.Dev), 577 - ) 578 - if err != nil { 579 - fail("Failed to connect to knotserver", nil) 580 - return 581 - } 582 - 583 - err = tangled.RepoDeleteBranch( 584 - r.Context(), 585 - client, 586 - &tangled.RepoDeleteBranch_Input{ 587 - Branch: branch, 588 - Repo: f.RepoAt().String(), 589 - }, 590 - ) 591 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 592 - fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 593 - return 594 - } 595 - l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 596 - 597 - rp.pages.HxRefresh(w) 598 - } 599 - 600 - func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 601 - l := rp.logger.With("handler", "RepoBlob") 602 - 603 - f, err := rp.repoResolver.Resolve(r) 604 - if err != nil { 605 - l.Error("failed to get repo and knot", "err", err) 606 - return 607 - } 608 - 609 - ref := chi.URLParam(r, "ref") 610 - ref, _ = url.PathUnescape(ref) 611 - 612 - filePath := chi.URLParam(r, "*") 613 - filePath, _ = url.PathUnescape(filePath) 614 - 615 - scheme := "http" 616 - if !rp.config.Core.Dev { 617 - scheme = "https" 618 - } 619 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 620 - xrpcc := &indigoxrpc.Client{ 621 - Host: host, 622 - } 623 - 624 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 625 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 626 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 627 - l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 628 - rp.pages.Error503(w) 629 - return 630 - } 631 - 632 - // Use XRPC response directly instead of converting to internal types 633 - 634 - var breadcrumbs [][]string 635 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 636 - if filePath != "" { 637 - for idx, elem := range strings.Split(filePath, "/") { 638 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 639 - } 640 - } 641 - 642 - showRendered := false 643 - renderToggle := false 644 - 645 - if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 646 - renderToggle = true 647 - showRendered = r.URL.Query().Get("code") != "true" 648 - } 649 - 650 - var unsupported bool 651 - var isImage bool 652 - var isVideo bool 653 - var contentSrc string 654 - 655 - if resp.IsBinary != nil && *resp.IsBinary { 656 - ext := strings.ToLower(filepath.Ext(resp.Path)) 657 - switch ext { 658 - case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 659 - isImage = true 660 - case ".mp4", ".webm", ".ogg", ".mov", ".avi": 661 - isVideo = true 662 - default: 663 - unsupported = true 664 - } 665 - 666 - // fetch the raw binary content using sh.tangled.repo.blob xrpc 667 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 668 - 669 - baseURL := &url.URL{ 670 - Scheme: scheme, 671 - Host: f.Knot, 672 - Path: "/xrpc/sh.tangled.repo.blob", 673 - } 674 - query := baseURL.Query() 675 - query.Set("repo", repoName) 676 - query.Set("ref", ref) 677 - query.Set("path", filePath) 678 - query.Set("raw", "true") 679 - baseURL.RawQuery = query.Encode() 680 - blobURL := baseURL.String() 681 - 682 - contentSrc = blobURL 683 - if !rp.config.Core.Dev { 684 - contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 685 - } 686 - } 687 - 688 - lines := 0 689 - if resp.IsBinary == nil || !*resp.IsBinary { 690 - lines = strings.Count(resp.Content, "\n") + 1 691 - } 692 - 693 - var sizeHint uint64 694 - if resp.Size != nil { 695 - sizeHint = uint64(*resp.Size) 696 - } else { 697 - sizeHint = uint64(len(resp.Content)) 698 - } 699 - 700 - user := rp.oauth.GetUser(r) 701 - 702 - // Determine if content is binary (dereference pointer) 703 - isBinary := false 704 - if resp.IsBinary != nil { 705 - isBinary = *resp.IsBinary 706 - } 707 - 708 - rp.pages.RepoBlob(w, pages.RepoBlobParams{ 709 - LoggedInUser: user, 710 - RepoInfo: f.RepoInfo(user), 711 - BreadCrumbs: breadcrumbs, 712 - ShowRendered: showRendered, 713 - RenderToggle: renderToggle, 714 - Unsupported: unsupported, 715 - IsImage: isImage, 716 - IsVideo: isVideo, 717 - ContentSrc: contentSrc, 718 - RepoBlob_Output: resp, 719 - Contents: resp.Content, 720 - Lines: lines, 721 - SizeHint: sizeHint, 722 - IsBinary: isBinary, 723 - }) 724 - } 725 - 726 - func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 727 - l := rp.logger.With("handler", "RepoBlobRaw") 728 - 729 - f, err := rp.repoResolver.Resolve(r) 730 - if err != nil { 731 - l.Error("failed to get repo and knot", "err", err) 732 - w.WriteHeader(http.StatusBadRequest) 733 - return 734 - } 735 - 736 - ref := chi.URLParam(r, "ref") 737 - ref, _ = url.PathUnescape(ref) 738 - 739 - filePath := chi.URLParam(r, "*") 740 - filePath, _ = url.PathUnescape(filePath) 741 - 742 - scheme := "http" 743 - if !rp.config.Core.Dev { 744 - scheme = "https" 745 - } 746 - 747 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 748 - baseURL := &url.URL{ 749 - Scheme: scheme, 750 - Host: f.Knot, 751 - Path: "/xrpc/sh.tangled.repo.blob", 752 - } 753 - query := baseURL.Query() 754 - query.Set("repo", repo) 755 - query.Set("ref", ref) 756 - query.Set("path", filePath) 757 - query.Set("raw", "true") 758 - baseURL.RawQuery = query.Encode() 759 - blobURL := baseURL.String() 760 - 761 - req, err := http.NewRequest("GET", blobURL, nil) 762 - if err != nil { 763 - l.Error("failed to create request", "err", err) 764 - return 765 - } 766 - 767 - // forward the If-None-Match header 768 - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 769 - req.Header.Set("If-None-Match", clientETag) 770 - } 771 - 772 - client := &http.Client{} 773 - resp, err := client.Do(req) 774 - if err != nil { 775 - l.Error("failed to reach knotserver", "err", err) 776 - rp.pages.Error503(w) 777 - return 778 - } 779 - defer resp.Body.Close() 780 - 781 - // forward 304 not modified 782 - if resp.StatusCode == http.StatusNotModified { 783 - w.WriteHeader(http.StatusNotModified) 784 - return 785 - } 786 - 787 - if resp.StatusCode != http.StatusOK { 788 - l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 789 - w.WriteHeader(resp.StatusCode) 790 - _, _ = io.Copy(w, resp.Body) 791 - return 792 - } 793 - 794 - contentType := resp.Header.Get("Content-Type") 795 - body, err := io.ReadAll(resp.Body) 796 - if err != nil { 797 - l.Error("error reading response body from knotserver", "err", err) 798 - w.WriteHeader(http.StatusInternalServerError) 799 - return 800 - } 801 - 802 - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 803 - // serve all textual content as text/plain 804 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 805 - w.Write(body) 806 - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 807 - // serve images and videos with their original content type 808 - w.Header().Set("Content-Type", contentType) 809 - w.Write(body) 810 - } else { 811 - w.WriteHeader(http.StatusUnsupportedMediaType) 812 - w.Write([]byte("unsupported content type")) 813 - return 814 - } 815 - } 816 - 817 // isTextualMimeType returns true if the MIME type represents textual content 818 - // that should be served as text/plain 819 - func isTextualMimeType(mimeType string) bool { 820 - textualTypes := []string{ 821 - "application/json", 822 - "application/xml", 823 - "application/yaml", 824 - "application/x-yaml", 825 - "application/toml", 826 - "application/javascript", 827 - "application/ecmascript", 828 - "message/", 829 - } 830 - 831 - return slices.Contains(textualTypes, mimeType) 832 - } 833 834 // modify the spindle configured for this repo 835 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { ··· 1686 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1687 } 1688 1689 - func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { 1690 - l := rp.logger.With("handler", "EditBaseSettings") 1691 - 1692 - noticeId := "repo-base-settings-error" 1693 - 1694 - f, err := rp.repoResolver.Resolve(r) 1695 - if err != nil { 1696 - l.Error("failed to get repo and knot", "err", err) 1697 - w.WriteHeader(http.StatusBadRequest) 1698 - return 1699 - } 1700 - 1701 - client, err := rp.oauth.AuthorizedClient(r) 1702 - if err != nil { 1703 - l.Error("failed to get client") 1704 - rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.") 1705 - return 1706 - } 1707 - 1708 - var ( 1709 - description = r.FormValue("description") 1710 - website = r.FormValue("website") 1711 - topicStr = r.FormValue("topics") 1712 - ) 1713 - 1714 - err = rp.validator.ValidateURI(website) 1715 - if err != nil { 1716 - l.Error("invalid uri", "err", err) 1717 - rp.pages.Notice(w, noticeId, err.Error()) 1718 - return 1719 - } 1720 - 1721 - topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 1722 - if err != nil { 1723 - l.Error("invalid topics", "err", err) 1724 - rp.pages.Notice(w, noticeId, err.Error()) 1725 - return 1726 - } 1727 - l.Debug("got", "topicsStr", topicStr, "topics", topics) 1728 - 1729 - newRepo := f.Repo 1730 - newRepo.Description = description 1731 - newRepo.Website = website 1732 - newRepo.Topics = topics 1733 - record := newRepo.AsRecord() 1734 - 1735 - tx, err := rp.db.BeginTx(r.Context(), nil) 1736 - if err != nil { 1737 - l.Error("failed to begin transaction", "err", err) 1738 - rp.pages.Notice(w, noticeId, "Failed to save repository information.") 1739 - return 1740 - } 1741 - defer tx.Rollback() 1742 - 1743 - err = db.PutRepo(tx, newRepo) 1744 - if err != nil { 1745 - l.Error("failed to update repository", "err", err) 1746 - rp.pages.Notice(w, noticeId, "Failed to save repository information.") 1747 - return 1748 - } 1749 - 1750 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1751 - if err != nil { 1752 - // failed to get record 1753 - l.Error("failed to get repo record", "err", err) 1754 - rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.") 1755 - return 1756 - } 1757 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1758 - Collection: tangled.RepoNSID, 1759 - Repo: newRepo.Did, 1760 - Rkey: newRepo.Rkey, 1761 - SwapRecord: ex.Cid, 1762 - Record: &lexutil.LexiconTypeDecoder{ 1763 - Val: &record, 1764 - }, 1765 - }) 1766 - 1767 - if err != nil { 1768 - l.Error("failed to perferom update-repo query", "err", err) 1769 - // failed to get record 1770 - rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.") 1771 - return 1772 - } 1773 - 1774 - err = tx.Commit() 1775 - if err != nil { 1776 - l.Error("failed to commit", "err", err) 1777 - } 1778 - 1779 - rp.pages.HxRefresh(w) 1780 - } 1781 - 1782 - func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1783 - l := rp.logger.With("handler", "SetDefaultBranch") 1784 - 1785 - f, err := rp.repoResolver.Resolve(r) 1786 - if err != nil { 1787 - l.Error("failed to get repo and knot", "err", err) 1788 - return 1789 - } 1790 - 1791 - noticeId := "operation-error" 1792 - branch := r.FormValue("branch") 1793 - if branch == "" { 1794 - http.Error(w, "malformed form", http.StatusBadRequest) 1795 - return 1796 - } 1797 - 1798 - client, err := rp.oauth.ServiceClient( 1799 - r, 1800 - oauth.WithService(f.Knot), 1801 - oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1802 - oauth.WithDev(rp.config.Core.Dev), 1803 - ) 1804 - if err != nil { 1805 - l.Error("failed to connect to knot server", "err", err) 1806 - rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1807 - return 1808 - } 1809 - 1810 - xe := tangled.RepoSetDefaultBranch( 1811 - r.Context(), 1812 - client, 1813 - &tangled.RepoSetDefaultBranch_Input{ 1814 - Repo: f.RepoAt().String(), 1815 - DefaultBranch: branch, 1816 - }, 1817 - ) 1818 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1819 - l.Error("xrpc failed", "err", xe) 1820 - rp.pages.Notice(w, noticeId, err.Error()) 1821 - return 1822 - } 1823 - 1824 - rp.pages.HxRefresh(w) 1825 - } 1826 - 1827 - func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1828 - user := rp.oauth.GetUser(r) 1829 - l := rp.logger.With("handler", "Secrets") 1830 - l = l.With("did", user.Did) 1831 - 1832 - f, err := rp.repoResolver.Resolve(r) 1833 - if err != nil { 1834 - l.Error("failed to get repo and knot", "err", err) 1835 - return 1836 - } 1837 - 1838 - if f.Spindle == "" { 1839 - l.Error("empty spindle cannot add/rm secret", "err", err) 1840 - return 1841 - } 1842 - 1843 - lxm := tangled.RepoAddSecretNSID 1844 - if r.Method == http.MethodDelete { 1845 - lxm = tangled.RepoRemoveSecretNSID 1846 - } 1847 - 1848 - spindleClient, err := rp.oauth.ServiceClient( 1849 - r, 1850 - oauth.WithService(f.Spindle), 1851 - oauth.WithLxm(lxm), 1852 - oauth.WithExp(60), 1853 - oauth.WithDev(rp.config.Core.Dev), 1854 - ) 1855 - if err != nil { 1856 - l.Error("failed to create spindle client", "err", err) 1857 - return 1858 - } 1859 - 1860 - key := r.FormValue("key") 1861 - if key == "" { 1862 - w.WriteHeader(http.StatusBadRequest) 1863 - return 1864 - } 1865 - 1866 - switch r.Method { 1867 - case http.MethodPut: 1868 - errorId := "add-secret-error" 1869 - 1870 - value := r.FormValue("value") 1871 - if value == "" { 1872 - w.WriteHeader(http.StatusBadRequest) 1873 - return 1874 - } 1875 - 1876 - err = tangled.RepoAddSecret( 1877 - r.Context(), 1878 - spindleClient, 1879 - &tangled.RepoAddSecret_Input{ 1880 - Repo: f.RepoAt().String(), 1881 - Key: key, 1882 - Value: value, 1883 - }, 1884 - ) 1885 - if err != nil { 1886 - l.Error("Failed to add secret.", "err", err) 1887 - rp.pages.Notice(w, errorId, "Failed to add secret.") 1888 - return 1889 - } 1890 - 1891 - case http.MethodDelete: 1892 - errorId := "operation-error" 1893 - 1894 - err = tangled.RepoRemoveSecret( 1895 - r.Context(), 1896 - spindleClient, 1897 - &tangled.RepoRemoveSecret_Input{ 1898 - Repo: f.RepoAt().String(), 1899 - Key: key, 1900 - }, 1901 - ) 1902 - if err != nil { 1903 - l.Error("Failed to delete secret.", "err", err) 1904 - rp.pages.Notice(w, errorId, "Failed to delete secret.") 1905 - return 1906 - } 1907 - } 1908 - 1909 - rp.pages.HxRefresh(w) 1910 - } 1911 - 1912 - type tab = map[string]any 1913 - 1914 - var ( 1915 - // would be great to have ordered maps right about now 1916 - settingsTabs []tab = []tab{ 1917 - {"Name": "general", "Icon": "sliders-horizontal"}, 1918 - {"Name": "access", "Icon": "users"}, 1919 - {"Name": "pipelines", "Icon": "layers-2"}, 1920 - } 1921 - ) 1922 - 1923 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1924 - tabVal := r.URL.Query().Get("tab") 1925 - if tabVal == "" { 1926 - tabVal = "general" 1927 - } 1928 - 1929 - switch tabVal { 1930 - case "general": 1931 - rp.generalSettings(w, r) 1932 - 1933 - case "access": 1934 - rp.accessSettings(w, r) 1935 - 1936 - case "pipelines": 1937 - rp.pipelineSettings(w, r) 1938 - } 1939 - } 1940 - 1941 - func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1942 - l := rp.logger.With("handler", "generalSettings") 1943 - 1944 - f, err := rp.repoResolver.Resolve(r) 1945 - user := rp.oauth.GetUser(r) 1946 - 1947 - scheme := "http" 1948 - if !rp.config.Core.Dev { 1949 - scheme = "https" 1950 - } 1951 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1952 - xrpcc := &indigoxrpc.Client{ 1953 - Host: host, 1954 - } 1955 - 1956 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1957 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1958 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1959 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1960 - rp.pages.Error503(w) 1961 - return 1962 - } 1963 - 1964 - var result types.RepoBranchesResponse 1965 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1966 - l.Error("failed to decode XRPC response", "err", err) 1967 - rp.pages.Error503(w) 1968 - return 1969 - } 1970 - 1971 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) 1972 - if err != nil { 1973 - l.Error("failed to fetch labels", "err", err) 1974 - rp.pages.Error503(w) 1975 - return 1976 - } 1977 - 1978 - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1979 - if err != nil { 1980 - l.Error("failed to fetch labels", "err", err) 1981 - rp.pages.Error503(w) 1982 - return 1983 - } 1984 - // remove default labels from the labels list, if present 1985 - defaultLabelMap := make(map[string]bool) 1986 - for _, dl := range defaultLabels { 1987 - defaultLabelMap[dl.AtUri().String()] = true 1988 - } 1989 - n := 0 1990 - for _, l := range labels { 1991 - if !defaultLabelMap[l.AtUri().String()] { 1992 - labels[n] = l 1993 - n++ 1994 - } 1995 - } 1996 - labels = labels[:n] 1997 - 1998 - subscribedLabels := make(map[string]struct{}) 1999 - for _, l := range f.Repo.Labels { 2000 - subscribedLabels[l] = struct{}{} 2001 - } 2002 - 2003 - // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 2004 - // if all default labels are subbed, show the "unsubscribe all" button 2005 - shouldSubscribeAll := false 2006 - for _, dl := range defaultLabels { 2007 - if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 2008 - // one of the default labels is not subscribed to 2009 - shouldSubscribeAll = true 2010 - break 2011 - } 2012 - } 2013 - 2014 - rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 2015 - LoggedInUser: user, 2016 - RepoInfo: f.RepoInfo(user), 2017 - Branches: result.Branches, 2018 - Labels: labels, 2019 - DefaultLabels: defaultLabels, 2020 - SubscribedLabels: subscribedLabels, 2021 - ShouldSubscribeAll: shouldSubscribeAll, 2022 - Tabs: settingsTabs, 2023 - Tab: "general", 2024 - }) 2025 - } 2026 - 2027 - func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 2028 - l := rp.logger.With("handler", "accessSettings") 2029 - 2030 - f, err := rp.repoResolver.Resolve(r) 2031 - user := rp.oauth.GetUser(r) 2032 - 2033 - repoCollaborators, err := f.Collaborators(r.Context()) 2034 - if err != nil { 2035 - l.Error("failed to get collaborators", "err", err) 2036 - } 2037 - 2038 - rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 2039 - LoggedInUser: user, 2040 - RepoInfo: f.RepoInfo(user), 2041 - Tabs: settingsTabs, 2042 - Tab: "access", 2043 - Collaborators: repoCollaborators, 2044 - }) 2045 - } 2046 - 2047 - func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 2048 - l := rp.logger.With("handler", "pipelineSettings") 2049 - 2050 - f, err := rp.repoResolver.Resolve(r) 2051 - user := rp.oauth.GetUser(r) 2052 - 2053 - // all spindles that the repo owner is a member of 2054 - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 2055 - if err != nil { 2056 - l.Error("failed to fetch spindles", "err", err) 2057 - return 2058 - } 2059 - 2060 - var secrets []*tangled.RepoListSecrets_Secret 2061 - if f.Spindle != "" { 2062 - if spindleClient, err := rp.oauth.ServiceClient( 2063 - r, 2064 - oauth.WithService(f.Spindle), 2065 - oauth.WithLxm(tangled.RepoListSecretsNSID), 2066 - oauth.WithExp(60), 2067 - oauth.WithDev(rp.config.Core.Dev), 2068 - ); err != nil { 2069 - l.Error("failed to create spindle client", "err", err) 2070 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2071 - l.Error("failed to fetch secrets", "err", err) 2072 - } else { 2073 - secrets = resp.Secrets 2074 - } 2075 - } 2076 - 2077 - slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 2078 - return strings.Compare(a.Key, b.Key) 2079 - }) 2080 - 2081 - var dids []string 2082 - for _, s := range secrets { 2083 - dids = append(dids, s.CreatedBy) 2084 - } 2085 - resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 2086 - 2087 - // convert to a more manageable form 2088 - var niceSecret []map[string]any 2089 - for id, s := range secrets { 2090 - when, _ := time.Parse(time.RFC3339, s.CreatedAt) 2091 - niceSecret = append(niceSecret, map[string]any{ 2092 - "Id": id, 2093 - "Key": s.Key, 2094 - "CreatedAt": when, 2095 - "CreatedBy": resolvedIdents[id].Handle.String(), 2096 - }) 2097 - } 2098 - 2099 - rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 2100 - LoggedInUser: user, 2101 - RepoInfo: f.RepoInfo(user), 2102 - Tabs: settingsTabs, 2103 - Tab: "pipelines", 2104 - Spindles: spindles, 2105 - CurrentSpindle: f.Spindle, 2106 - Secrets: niceSecret, 2107 - }) 2108 - } 2109 - 2110 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2111 l := rp.logger.With("handler", "SyncRepoFork") 2112 ··· 2388 }) 2389 return err 2390 } 2391 - 2392 - func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2393 - l := rp.logger.With("handler", "RepoCompareNew") 2394 - 2395 - user := rp.oauth.GetUser(r) 2396 - f, err := rp.repoResolver.Resolve(r) 2397 - if err != nil { 2398 - l.Error("failed to get repo and knot", "err", err) 2399 - return 2400 - } 2401 - 2402 - scheme := "http" 2403 - if !rp.config.Core.Dev { 2404 - scheme = "https" 2405 - } 2406 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2407 - xrpcc := &indigoxrpc.Client{ 2408 - Host: host, 2409 - } 2410 - 2411 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2412 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2413 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2414 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2415 - rp.pages.Error503(w) 2416 - return 2417 - } 2418 - 2419 - var branchResult types.RepoBranchesResponse 2420 - if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2421 - l.Error("failed to decode XRPC branches response", "err", err) 2422 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2423 - return 2424 - } 2425 - branches := branchResult.Branches 2426 - 2427 - sortBranches(branches) 2428 - 2429 - var defaultBranch string 2430 - for _, b := range branches { 2431 - if b.IsDefault { 2432 - defaultBranch = b.Name 2433 - } 2434 - } 2435 - 2436 - base := defaultBranch 2437 - head := defaultBranch 2438 - 2439 - params := r.URL.Query() 2440 - queryBase := params.Get("base") 2441 - queryHead := params.Get("head") 2442 - if queryBase != "" { 2443 - base = queryBase 2444 - } 2445 - if queryHead != "" { 2446 - head = queryHead 2447 - } 2448 - 2449 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2450 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2451 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2452 - rp.pages.Error503(w) 2453 - return 2454 - } 2455 - 2456 - var tags types.RepoTagsResponse 2457 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2458 - l.Error("failed to decode XRPC tags response", "err", err) 2459 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2460 - return 2461 - } 2462 - 2463 - repoinfo := f.RepoInfo(user) 2464 - 2465 - rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 2466 - LoggedInUser: user, 2467 - RepoInfo: repoinfo, 2468 - Branches: branches, 2469 - Tags: tags.Tags, 2470 - Base: base, 2471 - Head: head, 2472 - }) 2473 - } 2474 - 2475 - func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2476 - l := rp.logger.With("handler", "RepoCompare") 2477 - 2478 - user := rp.oauth.GetUser(r) 2479 - f, err := rp.repoResolver.Resolve(r) 2480 - if err != nil { 2481 - l.Error("failed to get repo and knot", "err", err) 2482 - return 2483 - } 2484 - 2485 - var diffOpts types.DiffOpts 2486 - if d := r.URL.Query().Get("diff"); d == "split" { 2487 - diffOpts.Split = true 2488 - } 2489 - 2490 - // if user is navigating to one of 2491 - // /compare/{base}/{head} 2492 - // /compare/{base}...{head} 2493 - base := chi.URLParam(r, "base") 2494 - head := chi.URLParam(r, "head") 2495 - if base == "" && head == "" { 2496 - rest := chi.URLParam(r, "*") // master...feature/xyz 2497 - parts := strings.SplitN(rest, "...", 2) 2498 - if len(parts) == 2 { 2499 - base = parts[0] 2500 - head = parts[1] 2501 - } 2502 - } 2503 - 2504 - base, _ = url.PathUnescape(base) 2505 - head, _ = url.PathUnescape(head) 2506 - 2507 - if base == "" || head == "" { 2508 - l.Error("invalid comparison") 2509 - rp.pages.Error404(w) 2510 - return 2511 - } 2512 - 2513 - scheme := "http" 2514 - if !rp.config.Core.Dev { 2515 - scheme = "https" 2516 - } 2517 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2518 - xrpcc := &indigoxrpc.Client{ 2519 - Host: host, 2520 - } 2521 - 2522 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2523 - 2524 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2525 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2526 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2527 - rp.pages.Error503(w) 2528 - return 2529 - } 2530 - 2531 - var branches types.RepoBranchesResponse 2532 - if err := json.Unmarshal(branchBytes, &branches); err != nil { 2533 - l.Error("failed to decode XRPC branches response", "err", err) 2534 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2535 - return 2536 - } 2537 - 2538 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2539 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2540 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2541 - rp.pages.Error503(w) 2542 - return 2543 - } 2544 - 2545 - var tags types.RepoTagsResponse 2546 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2547 - l.Error("failed to decode XRPC tags response", "err", err) 2548 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2549 - return 2550 - } 2551 - 2552 - compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2553 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2554 - l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 2555 - rp.pages.Error503(w) 2556 - return 2557 - } 2558 - 2559 - var formatPatch types.RepoFormatPatchResponse 2560 - if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2561 - l.Error("failed to decode XRPC compare response", "err", err) 2562 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2563 - return 2564 - } 2565 - 2566 - var diff types.NiceDiff 2567 - if formatPatch.CombinedPatchRaw != "" { 2568 - diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 2569 - } else { 2570 - diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 2571 - } 2572 - 2573 - repoinfo := f.RepoInfo(user) 2574 - 2575 - rp.pages.RepoCompare(w, pages.RepoCompareParams{ 2576 - LoggedInUser: user, 2577 - RepoInfo: repoinfo, 2578 - Branches: branches.Branches, 2579 - Tags: tags.Tags, 2580 - Base: base, 2581 - Head: head, 2582 - Diff: &diff, 2583 - DiffOpts: diffOpts, 2584 - }) 2585 - 2586 - }
··· 3 import ( 4 "context" 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "net/url" 11 "slices" 12 "strings" 13 "time" 14 15 "tangled.org/core/api/tangled" 16 "tangled.org/core/appview/config" 17 "tangled.org/core/appview/db" 18 "tangled.org/core/appview/models" 19 "tangled.org/core/appview/notify" 20 "tangled.org/core/appview/oauth" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/reporesolver" 23 "tangled.org/core/appview/validator" 24 xrpcclient "tangled.org/core/appview/xrpcclient" 25 "tangled.org/core/eventconsumer" 26 "tangled.org/core/idresolver" 27 "tangled.org/core/rbac" 28 "tangled.org/core/tid" 29 "tangled.org/core/xrpc/serviceauth" 30 31 comatproto "github.com/bluesky-social/indigo/api/atproto" 32 atpclient "github.com/bluesky-social/indigo/atproto/client" 33 "github.com/bluesky-social/indigo/atproto/syntax" 34 lexutil "github.com/bluesky-social/indigo/lex/util" 35 securejoin "github.com/cyphar/filepath-securejoin" 36 "github.com/go-chi/chi/v5" 37 ) 38 39 type Repo struct { ··· 78 } 79 } 80 81 // isTextualMimeType returns true if the MIME type represents textual content 82 83 // modify the spindle configured for this repo 84 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { ··· 935 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 936 } 937 938 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 939 l := rp.logger.With("handler", "SyncRepoFork") 940 ··· 1216 }) 1217 return err 1218 }
+14 -14
appview/repo/router.go
··· 9 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 - r.Get("/", rp.RepoIndex) 13 - r.Get("/opengraph", rp.RepoOpenGraphSummary) 14 - r.Get("/feed.atom", rp.RepoAtomFeed) 15 - r.Get("/commits/{ref}", rp.RepoLog) 16 r.Route("/tree/{ref}", func(r chi.Router) { 17 - r.Get("/", rp.RepoIndex) 18 - r.Get("/*", rp.RepoTree) 19 }) 20 - r.Get("/commit/{ref}", rp.RepoCommit) 21 - r.Get("/branches", rp.RepoBranches) 22 r.Delete("/branches", rp.DeleteBranch) 23 r.Route("/tags", func(r chi.Router) { 24 - r.Get("/", rp.RepoTags) 25 r.Route("/{tag}", func(r chi.Router) { 26 r.Get("/download/{file}", rp.DownloadArtifact) 27 ··· 37 }) 38 }) 39 }) 40 - r.Get("/blob/{ref}/*", rp.RepoBlob) 41 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 42 43 // intentionally doesn't use /* as this isn't ··· 54 }) 55 56 r.Route("/compare", func(r chi.Router) { 57 - r.Get("/", rp.RepoCompareNew) // start an new comparison 58 59 // we have to wildcard here since we want to support GitHub's compare syntax 60 // /compare/{ref1}...{ref2} 61 // for example: 62 // /compare/master...some/feature 63 // /compare/master...example.com:another/feature <- this is a fork 64 - r.Get("/{base}/{head}", rp.RepoCompare) 65 - r.Get("/*", rp.RepoCompare) 66 }) 67 68 // label panel in issues/pulls/discussions/tasks ··· 75 r.Group(func(r chi.Router) { 76 r.Use(middleware.AuthMiddleware(rp.oauth)) 77 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 78 - r.Get("/", rp.RepoSettings) 79 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings) 80 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 81 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
··· 9 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 + r.Get("/", rp.Index) 13 + r.Get("/opengraph", rp.Opengraph) 14 + r.Get("/feed.atom", rp.AtomFeed) 15 + r.Get("/commits/{ref}", rp.Log) 16 r.Route("/tree/{ref}", func(r chi.Router) { 17 + r.Get("/", rp.Index) 18 + r.Get("/*", rp.Tree) 19 }) 20 + r.Get("/commit/{ref}", rp.Commit) 21 + r.Get("/branches", rp.Branches) 22 r.Delete("/branches", rp.DeleteBranch) 23 r.Route("/tags", func(r chi.Router) { 24 + r.Get("/", rp.Tags) 25 r.Route("/{tag}", func(r chi.Router) { 26 r.Get("/download/{file}", rp.DownloadArtifact) 27 ··· 37 }) 38 }) 39 }) 40 + r.Get("/blob/{ref}/*", rp.Blob) 41 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 42 43 // intentionally doesn't use /* as this isn't ··· 54 }) 55 56 r.Route("/compare", func(r chi.Router) { 57 + r.Get("/", rp.CompareNew) // start an new comparison 58 59 // we have to wildcard here since we want to support GitHub's compare syntax 60 // /compare/{ref1}...{ref2} 61 // for example: 62 // /compare/master...some/feature 63 // /compare/master...example.com:another/feature <- this is a fork 64 + r.Get("/{base}/{head}", rp.Compare) 65 + r.Get("/*", rp.Compare) 66 }) 67 68 // label panel in issues/pulls/discussions/tasks ··· 75 r.Group(func(r chi.Router) { 76 r.Use(middleware.AuthMiddleware(rp.oauth)) 77 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 78 + r.Get("/", rp.Settings) 79 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings) 80 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 81 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
+442
appview/repo/settings.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "slices" 8 + "strings" 9 + "time" 10 + 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/oauth" 14 + "tangled.org/core/appview/pages" 15 + xrpcclient "tangled.org/core/appview/xrpcclient" 16 + "tangled.org/core/types" 17 + 18 + comatproto "github.com/bluesky-social/indigo/api/atproto" 19 + lexutil "github.com/bluesky-social/indigo/lex/util" 20 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 21 + ) 22 + 23 + type tab = map[string]any 24 + 25 + var ( 26 + // would be great to have ordered maps right about now 27 + settingsTabs []tab = []tab{ 28 + {"Name": "general", "Icon": "sliders-horizontal"}, 29 + {"Name": "access", "Icon": "users"}, 30 + {"Name": "pipelines", "Icon": "layers-2"}, 31 + } 32 + ) 33 + 34 + func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 35 + l := rp.logger.With("handler", "SetDefaultBranch") 36 + 37 + f, err := rp.repoResolver.Resolve(r) 38 + if err != nil { 39 + l.Error("failed to get repo and knot", "err", err) 40 + return 41 + } 42 + 43 + noticeId := "operation-error" 44 + branch := r.FormValue("branch") 45 + if branch == "" { 46 + http.Error(w, "malformed form", http.StatusBadRequest) 47 + return 48 + } 49 + 50 + client, err := rp.oauth.ServiceClient( 51 + r, 52 + oauth.WithService(f.Knot), 53 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 54 + oauth.WithDev(rp.config.Core.Dev), 55 + ) 56 + if err != nil { 57 + l.Error("failed to connect to knot server", "err", err) 58 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 59 + return 60 + } 61 + 62 + xe := tangled.RepoSetDefaultBranch( 63 + r.Context(), 64 + client, 65 + &tangled.RepoSetDefaultBranch_Input{ 66 + Repo: f.RepoAt().String(), 67 + DefaultBranch: branch, 68 + }, 69 + ) 70 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 71 + l.Error("xrpc failed", "err", xe) 72 + rp.pages.Notice(w, noticeId, err.Error()) 73 + return 74 + } 75 + 76 + rp.pages.HxRefresh(w) 77 + } 78 + 79 + func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 80 + user := rp.oauth.GetUser(r) 81 + l := rp.logger.With("handler", "Secrets") 82 + l = l.With("did", user.Did) 83 + 84 + f, err := rp.repoResolver.Resolve(r) 85 + if err != nil { 86 + l.Error("failed to get repo and knot", "err", err) 87 + return 88 + } 89 + 90 + if f.Spindle == "" { 91 + l.Error("empty spindle cannot add/rm secret", "err", err) 92 + return 93 + } 94 + 95 + lxm := tangled.RepoAddSecretNSID 96 + if r.Method == http.MethodDelete { 97 + lxm = tangled.RepoRemoveSecretNSID 98 + } 99 + 100 + spindleClient, err := rp.oauth.ServiceClient( 101 + r, 102 + oauth.WithService(f.Spindle), 103 + oauth.WithLxm(lxm), 104 + oauth.WithExp(60), 105 + oauth.WithDev(rp.config.Core.Dev), 106 + ) 107 + if err != nil { 108 + l.Error("failed to create spindle client", "err", err) 109 + return 110 + } 111 + 112 + key := r.FormValue("key") 113 + if key == "" { 114 + w.WriteHeader(http.StatusBadRequest) 115 + return 116 + } 117 + 118 + switch r.Method { 119 + case http.MethodPut: 120 + errorId := "add-secret-error" 121 + 122 + value := r.FormValue("value") 123 + if value == "" { 124 + w.WriteHeader(http.StatusBadRequest) 125 + return 126 + } 127 + 128 + err = tangled.RepoAddSecret( 129 + r.Context(), 130 + spindleClient, 131 + &tangled.RepoAddSecret_Input{ 132 + Repo: f.RepoAt().String(), 133 + Key: key, 134 + Value: value, 135 + }, 136 + ) 137 + if err != nil { 138 + l.Error("Failed to add secret.", "err", err) 139 + rp.pages.Notice(w, errorId, "Failed to add secret.") 140 + return 141 + } 142 + 143 + case http.MethodDelete: 144 + errorId := "operation-error" 145 + 146 + err = tangled.RepoRemoveSecret( 147 + r.Context(), 148 + spindleClient, 149 + &tangled.RepoRemoveSecret_Input{ 150 + Repo: f.RepoAt().String(), 151 + Key: key, 152 + }, 153 + ) 154 + if err != nil { 155 + l.Error("Failed to delete secret.", "err", err) 156 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 157 + return 158 + } 159 + } 160 + 161 + rp.pages.HxRefresh(w) 162 + } 163 + 164 + func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) { 165 + tabVal := r.URL.Query().Get("tab") 166 + if tabVal == "" { 167 + tabVal = "general" 168 + } 169 + 170 + switch tabVal { 171 + case "general": 172 + rp.generalSettings(w, r) 173 + 174 + case "access": 175 + rp.accessSettings(w, r) 176 + 177 + case "pipelines": 178 + rp.pipelineSettings(w, r) 179 + } 180 + } 181 + 182 + func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 183 + l := rp.logger.With("handler", "generalSettings") 184 + 185 + f, err := rp.repoResolver.Resolve(r) 186 + user := rp.oauth.GetUser(r) 187 + 188 + scheme := "http" 189 + if !rp.config.Core.Dev { 190 + scheme = "https" 191 + } 192 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 193 + xrpcc := &indigoxrpc.Client{ 194 + Host: host, 195 + } 196 + 197 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 198 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 199 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 200 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 201 + rp.pages.Error503(w) 202 + return 203 + } 204 + 205 + var result types.RepoBranchesResponse 206 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 207 + l.Error("failed to decode XRPC response", "err", err) 208 + rp.pages.Error503(w) 209 + return 210 + } 211 + 212 + defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) 213 + if err != nil { 214 + l.Error("failed to fetch labels", "err", err) 215 + rp.pages.Error503(w) 216 + return 217 + } 218 + 219 + labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 220 + if err != nil { 221 + l.Error("failed to fetch labels", "err", err) 222 + rp.pages.Error503(w) 223 + return 224 + } 225 + // remove default labels from the labels list, if present 226 + defaultLabelMap := make(map[string]bool) 227 + for _, dl := range defaultLabels { 228 + defaultLabelMap[dl.AtUri().String()] = true 229 + } 230 + n := 0 231 + for _, l := range labels { 232 + if !defaultLabelMap[l.AtUri().String()] { 233 + labels[n] = l 234 + n++ 235 + } 236 + } 237 + labels = labels[:n] 238 + 239 + subscribedLabels := make(map[string]struct{}) 240 + for _, l := range f.Repo.Labels { 241 + subscribedLabels[l] = struct{}{} 242 + } 243 + 244 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 245 + // if all default labels are subbed, show the "unsubscribe all" button 246 + shouldSubscribeAll := false 247 + for _, dl := range defaultLabels { 248 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 249 + // one of the default labels is not subscribed to 250 + shouldSubscribeAll = true 251 + break 252 + } 253 + } 254 + 255 + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 256 + LoggedInUser: user, 257 + RepoInfo: f.RepoInfo(user), 258 + Branches: result.Branches, 259 + Labels: labels, 260 + DefaultLabels: defaultLabels, 261 + SubscribedLabels: subscribedLabels, 262 + ShouldSubscribeAll: shouldSubscribeAll, 263 + Tabs: settingsTabs, 264 + Tab: "general", 265 + }) 266 + } 267 + 268 + func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 269 + l := rp.logger.With("handler", "accessSettings") 270 + 271 + f, err := rp.repoResolver.Resolve(r) 272 + user := rp.oauth.GetUser(r) 273 + 274 + repoCollaborators, err := f.Collaborators(r.Context()) 275 + if err != nil { 276 + l.Error("failed to get collaborators", "err", err) 277 + } 278 + 279 + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 280 + LoggedInUser: user, 281 + RepoInfo: f.RepoInfo(user), 282 + Tabs: settingsTabs, 283 + Tab: "access", 284 + Collaborators: repoCollaborators, 285 + }) 286 + } 287 + 288 + func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 289 + l := rp.logger.With("handler", "pipelineSettings") 290 + 291 + f, err := rp.repoResolver.Resolve(r) 292 + user := rp.oauth.GetUser(r) 293 + 294 + // all spindles that the repo owner is a member of 295 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 296 + if err != nil { 297 + l.Error("failed to fetch spindles", "err", err) 298 + return 299 + } 300 + 301 + var secrets []*tangled.RepoListSecrets_Secret 302 + if f.Spindle != "" { 303 + if spindleClient, err := rp.oauth.ServiceClient( 304 + r, 305 + oauth.WithService(f.Spindle), 306 + oauth.WithLxm(tangled.RepoListSecretsNSID), 307 + oauth.WithExp(60), 308 + oauth.WithDev(rp.config.Core.Dev), 309 + ); err != nil { 310 + l.Error("failed to create spindle client", "err", err) 311 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 312 + l.Error("failed to fetch secrets", "err", err) 313 + } else { 314 + secrets = resp.Secrets 315 + } 316 + } 317 + 318 + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 319 + return strings.Compare(a.Key, b.Key) 320 + }) 321 + 322 + var dids []string 323 + for _, s := range secrets { 324 + dids = append(dids, s.CreatedBy) 325 + } 326 + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 327 + 328 + // convert to a more manageable form 329 + var niceSecret []map[string]any 330 + for id, s := range secrets { 331 + when, _ := time.Parse(time.RFC3339, s.CreatedAt) 332 + niceSecret = append(niceSecret, map[string]any{ 333 + "Id": id, 334 + "Key": s.Key, 335 + "CreatedAt": when, 336 + "CreatedBy": resolvedIdents[id].Handle.String(), 337 + }) 338 + } 339 + 340 + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 341 + LoggedInUser: user, 342 + RepoInfo: f.RepoInfo(user), 343 + Tabs: settingsTabs, 344 + Tab: "pipelines", 345 + Spindles: spindles, 346 + CurrentSpindle: f.Spindle, 347 + Secrets: niceSecret, 348 + }) 349 + } 350 + 351 + func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { 352 + l := rp.logger.With("handler", "EditBaseSettings") 353 + 354 + noticeId := "repo-base-settings-error" 355 + 356 + f, err := rp.repoResolver.Resolve(r) 357 + if err != nil { 358 + l.Error("failed to get repo and knot", "err", err) 359 + w.WriteHeader(http.StatusBadRequest) 360 + return 361 + } 362 + 363 + client, err := rp.oauth.AuthorizedClient(r) 364 + if err != nil { 365 + l.Error("failed to get client") 366 + rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.") 367 + return 368 + } 369 + 370 + var ( 371 + description = r.FormValue("description") 372 + website = r.FormValue("website") 373 + topicStr = r.FormValue("topics") 374 + ) 375 + 376 + err = rp.validator.ValidateURI(website) 377 + if err != nil { 378 + l.Error("invalid uri", "err", err) 379 + rp.pages.Notice(w, noticeId, err.Error()) 380 + return 381 + } 382 + 383 + topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 384 + if err != nil { 385 + l.Error("invalid topics", "err", err) 386 + rp.pages.Notice(w, noticeId, err.Error()) 387 + return 388 + } 389 + l.Debug("got", "topicsStr", topicStr, "topics", topics) 390 + 391 + newRepo := f.Repo 392 + newRepo.Description = description 393 + newRepo.Website = website 394 + newRepo.Topics = topics 395 + record := newRepo.AsRecord() 396 + 397 + tx, err := rp.db.BeginTx(r.Context(), nil) 398 + if err != nil { 399 + l.Error("failed to begin transaction", "err", err) 400 + rp.pages.Notice(w, noticeId, "Failed to save repository information.") 401 + return 402 + } 403 + defer tx.Rollback() 404 + 405 + err = db.PutRepo(tx, newRepo) 406 + if err != nil { 407 + l.Error("failed to update repository", "err", err) 408 + rp.pages.Notice(w, noticeId, "Failed to save repository information.") 409 + return 410 + } 411 + 412 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 413 + if err != nil { 414 + // failed to get record 415 + l.Error("failed to get repo record", "err", err) 416 + rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.") 417 + return 418 + } 419 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 420 + Collection: tangled.RepoNSID, 421 + Repo: newRepo.Did, 422 + Rkey: newRepo.Rkey, 423 + SwapRecord: ex.Cid, 424 + Record: &lexutil.LexiconTypeDecoder{ 425 + Val: &record, 426 + }, 427 + }) 428 + 429 + if err != nil { 430 + l.Error("failed to perferom update-repo query", "err", err) 431 + // failed to get record 432 + rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.") 433 + return 434 + } 435 + 436 + err = tx.Commit() 437 + if err != nil { 438 + l.Error("failed to commit", "err", err) 439 + } 440 + 441 + rp.pages.HxRefresh(w) 442 + }
+79
appview/repo/tags.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/pages" 12 + xrpcclient "tangled.org/core/appview/xrpcclient" 13 + "tangled.org/core/types" 14 + 15 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 + "github.com/go-git/go-git/v5/plumbing" 17 + ) 18 + 19 + func (rp *Repo) Tags(w http.ResponseWriter, r *http.Request) { 20 + l := rp.logger.With("handler", "RepoTags") 21 + f, err := rp.repoResolver.Resolve(r) 22 + if err != nil { 23 + l.Error("failed to get repo and knot", "err", err) 24 + return 25 + } 26 + scheme := "http" 27 + if !rp.config.Core.Dev { 28 + scheme = "https" 29 + } 30 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 31 + xrpcc := &indigoxrpc.Client{ 32 + Host: host, 33 + } 34 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 35 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 36 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 38 + rp.pages.Error503(w) 39 + return 40 + } 41 + var result types.RepoTagsResponse 42 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 43 + l.Error("failed to decode XRPC response", "err", err) 44 + rp.pages.Error503(w) 45 + return 46 + } 47 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 48 + if err != nil { 49 + l.Error("failed grab artifacts", "err", err) 50 + return 51 + } 52 + // convert artifacts to map for easy UI building 53 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 54 + for _, a := range artifacts { 55 + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 56 + } 57 + var danglingArtifacts []models.Artifact 58 + for _, a := range artifacts { 59 + found := false 60 + for _, t := range result.Tags { 61 + if t.Tag != nil { 62 + if t.Tag.Hash == a.Tag { 63 + found = true 64 + } 65 + } 66 + } 67 + if !found { 68 + danglingArtifacts = append(danglingArtifacts, a) 69 + } 70 + } 71 + user := rp.oauth.GetUser(r) 72 + rp.pages.RepoTags(w, pages.RepoTagsParams{ 73 + LoggedInUser: user, 74 + RepoInfo: f.RepoInfo(user), 75 + RepoTagsResponse: result, 76 + ArtifactMap: artifactMap, 77 + DanglingArtifacts: danglingArtifacts, 78 + }) 79 + }
+107
appview/repo/tree.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "net/url" 7 + "strings" 8 + "time" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/pages" 12 + xrpcclient "tangled.org/core/appview/xrpcclient" 13 + "tangled.org/core/types" 14 + 15 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 + "github.com/go-chi/chi/v5" 17 + "github.com/go-git/go-git/v5/plumbing" 18 + ) 19 + 20 + func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) { 21 + l := rp.logger.With("handler", "RepoTree") 22 + f, err := rp.repoResolver.Resolve(r) 23 + if err != nil { 24 + l.Error("failed to fully resolve repo", "err", err) 25 + return 26 + } 27 + ref := chi.URLParam(r, "ref") 28 + ref, _ = url.PathUnescape(ref) 29 + // if the tree path has a trailing slash, let's strip it 30 + // so we don't 404 31 + treePath := chi.URLParam(r, "*") 32 + treePath, _ = url.PathUnescape(treePath) 33 + treePath = strings.TrimSuffix(treePath, "/") 34 + scheme := "http" 35 + if !rp.config.Core.Dev { 36 + scheme = "https" 37 + } 38 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 39 + xrpcc := &indigoxrpc.Client{ 40 + Host: host, 41 + } 42 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 43 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 44 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 45 + l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 46 + rp.pages.Error503(w) 47 + return 48 + } 49 + // Convert XRPC response to internal types.RepoTreeResponse 50 + files := make([]types.NiceTree, len(xrpcResp.Files)) 51 + for i, xrpcFile := range xrpcResp.Files { 52 + file := types.NiceTree{ 53 + Name: xrpcFile.Name, 54 + Mode: xrpcFile.Mode, 55 + Size: int64(xrpcFile.Size), 56 + IsFile: xrpcFile.Is_file, 57 + IsSubtree: xrpcFile.Is_subtree, 58 + } 59 + // Convert last commit info if present 60 + if xrpcFile.Last_commit != nil { 61 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 62 + file.LastCommit = &types.LastCommitInfo{ 63 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 64 + Message: xrpcFile.Last_commit.Message, 65 + When: commitWhen, 66 + } 67 + } 68 + files[i] = file 69 + } 70 + result := types.RepoTreeResponse{ 71 + Ref: xrpcResp.Ref, 72 + Files: files, 73 + } 74 + if xrpcResp.Parent != nil { 75 + result.Parent = *xrpcResp.Parent 76 + } 77 + if xrpcResp.Dotdot != nil { 78 + result.DotDot = *xrpcResp.Dotdot 79 + } 80 + if xrpcResp.Readme != nil { 81 + result.ReadmeFileName = xrpcResp.Readme.Filename 82 + result.Readme = xrpcResp.Readme.Contents 83 + } 84 + // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 85 + // so we can safely redirect to the "parent" (which is the same file). 86 + if len(result.Files) == 0 && result.Parent == treePath { 87 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 88 + http.Redirect(w, r, redirectTo, http.StatusFound) 89 + return 90 + } 91 + user := rp.oauth.GetUser(r) 92 + var breadcrumbs [][]string 93 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 94 + if treePath != "" { 95 + for idx, elem := range strings.Split(treePath, "/") { 96 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 97 + } 98 + } 99 + sortFiles(result.Files) 100 + rp.pages.RepoTree(w, pages.RepoTreeParams{ 101 + LoggedInUser: user, 102 + BreadCrumbs: breadcrumbs, 103 + TreePath: treePath, 104 + RepoInfo: f.RepoInfo(user), 105 + RepoTreeResponse: result, 106 + }) 107 + }