Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

appview/repo: split up handlers into separate files

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

authored by

oppiliappan and committed by
Tangled
8692900c 926b6451

+1445 -1385
+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 146 return fmt.Sprintf("%s in %s", base, repoName) 147 147 } 148 148 149 - func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 149 + func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) { 150 150 f, err := rp.repoResolver.Resolve(r) 151 151 if err != nil { 152 152 log.Println("failed to fully resolve repo:", err)
+1 -1
appview/repo/index.go
··· 30 30 "github.com/go-enry/go-enry/v2" 31 31 ) 32 32 33 - func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 33 + func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) { 34 34 l := rp.logger.With("handler", "RepoIndex") 35 35 36 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 327 return nil 328 328 } 329 329 330 - func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 330 + func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { 331 331 f, err := rp.repoResolver.Resolve(r) 332 332 if err != nil { 333 333 log.Println("failed to get repo and knot", err)
-1368
appview/repo/repo.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 - "encoding/json" 7 6 "errors" 8 7 "fmt" 9 - "io" 10 8 "log/slog" 11 9 "net/http" 12 10 "net/url" 13 - "path/filepath" 14 11 "slices" 15 - "strconv" 16 12 "strings" 17 13 "time" 18 14 19 15 "tangled.org/core/api/tangled" 20 - "tangled.org/core/appview/commitverify" 21 16 "tangled.org/core/appview/config" 22 17 "tangled.org/core/appview/db" 23 18 "tangled.org/core/appview/models" 24 19 "tangled.org/core/appview/notify" 25 20 "tangled.org/core/appview/oauth" 26 21 "tangled.org/core/appview/pages" 27 - "tangled.org/core/appview/pages/markup" 28 22 "tangled.org/core/appview/reporesolver" 29 23 "tangled.org/core/appview/validator" 30 24 xrpcclient "tangled.org/core/appview/xrpcclient" 31 25 "tangled.org/core/eventconsumer" 32 26 "tangled.org/core/idresolver" 33 - "tangled.org/core/patchutil" 34 27 "tangled.org/core/rbac" 35 28 "tangled.org/core/tid" 36 - "tangled.org/core/types" 37 29 "tangled.org/core/xrpc/serviceauth" 38 30 39 31 comatproto "github.com/bluesky-social/indigo/api/atproto" 40 32 atpclient "github.com/bluesky-social/indigo/atproto/client" 41 33 "github.com/bluesky-social/indigo/atproto/syntax" 42 34 lexutil "github.com/bluesky-social/indigo/lex/util" 43 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 44 35 securejoin "github.com/cyphar/filepath-securejoin" 45 36 "github.com/go-chi/chi/v5" 46 - "github.com/go-git/go-git/v5/plumbing" 47 37 ) 48 38 49 39 type Repo struct { ··· 78 88 } 79 89 } 80 90 81 - func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 82 - l := rp.logger.With("handler", "DownloadArchive") 83 - 84 - ref := chi.URLParam(r, "ref") 85 - ref, _ = url.PathUnescape(ref) 86 - 87 - f, err := rp.repoResolver.Resolve(r) 88 - if err != nil { 89 - l.Error("failed to get repo and knot", "err", err) 90 - return 91 - } 92 - 93 - scheme := "http" 94 - if !rp.config.Core.Dev { 95 - scheme = "https" 96 - } 97 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 98 - xrpcc := &indigoxrpc.Client{ 99 - Host: host, 100 - } 101 - 102 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 103 - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 104 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 105 - l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 106 - rp.pages.Error503(w) 107 - return 108 - } 109 - 110 - // Set headers for file download, just pass along whatever the knot specifies 111 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 112 - filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 113 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 114 - w.Header().Set("Content-Type", "application/gzip") 115 - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 116 - 117 - // Write the archive data directly 118 - w.Write(archiveBytes) 119 - } 120 - 121 - func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 122 - l := rp.logger.With("handler", "RepoLog") 123 - 124 - f, err := rp.repoResolver.Resolve(r) 125 - if err != nil { 126 - l.Error("failed to fully resolve repo", "err", err) 127 - return 128 - } 129 - 130 - page := 1 131 - if r.URL.Query().Get("page") != "" { 132 - page, err = strconv.Atoi(r.URL.Query().Get("page")) 133 - if err != nil { 134 - page = 1 135 - } 136 - } 137 - 138 - ref := chi.URLParam(r, "ref") 139 - ref, _ = url.PathUnescape(ref) 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 - limit := int64(60) 151 - cursor := "" 152 - if page > 1 { 153 - // Convert page number to cursor (offset) 154 - offset := (page - 1) * int(limit) 155 - cursor = strconv.Itoa(offset) 156 - } 157 - 158 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 159 - xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 160 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 161 - l.Error("failed to call XRPC repo.log", "err", xrpcerr) 162 - rp.pages.Error503(w) 163 - return 164 - } 165 - 166 - var xrpcResp types.RepoLogResponse 167 - if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 168 - l.Error("failed to decode XRPC response", "err", err) 169 - rp.pages.Error503(w) 170 - return 171 - } 172 - 173 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 174 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 175 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 176 - rp.pages.Error503(w) 177 - return 178 - } 179 - 180 - tagMap := make(map[string][]string) 181 - if tagBytes != nil { 182 - var tagResp types.RepoTagsResponse 183 - if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 184 - for _, tag := range tagResp.Tags { 185 - hash := tag.Hash 186 - if tag.Tag != nil { 187 - hash = tag.Tag.Target.String() 188 - } 189 - tagMap[hash] = append(tagMap[hash], tag.Name) 190 - } 191 - } 192 - } 193 - 194 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 195 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 196 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 197 - rp.pages.Error503(w) 198 - return 199 - } 200 - 201 - if branchBytes != nil { 202 - var branchResp types.RepoBranchesResponse 203 - if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 204 - for _, branch := range branchResp.Branches { 205 - tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 206 - } 207 - } 208 - } 209 - 210 - user := rp.oauth.GetUser(r) 211 - 212 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 213 - if err != nil { 214 - l.Error("failed to fetch email to did mapping", "err", err) 215 - } 216 - 217 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 218 - if err != nil { 219 - l.Error("failed to GetVerifiedObjectCommits", "err", err) 220 - } 221 - 222 - repoInfo := f.RepoInfo(user) 223 - 224 - var shas []string 225 - for _, c := range xrpcResp.Commits { 226 - shas = append(shas, c.Hash.String()) 227 - } 228 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 229 - if err != nil { 230 - l.Error("failed to getPipelineStatuses", "err", err) 231 - // non-fatal 232 - } 233 - 234 - rp.pages.RepoLog(w, pages.RepoLogParams{ 235 - LoggedInUser: user, 236 - TagMap: tagMap, 237 - RepoInfo: repoInfo, 238 - RepoLogResponse: xrpcResp, 239 - EmailToDid: emailToDidMap, 240 - VerifiedCommits: vc, 241 - Pipelines: pipelines, 242 - }) 243 - } 244 - 245 - func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 246 - l := rp.logger.With("handler", "RepoCommit") 247 - 248 - f, err := rp.repoResolver.Resolve(r) 249 - if err != nil { 250 - l.Error("failed to fully resolve repo", "err", err) 251 - return 252 - } 253 - ref := chi.URLParam(r, "ref") 254 - ref, _ = url.PathUnescape(ref) 255 - 256 - var diffOpts types.DiffOpts 257 - if d := r.URL.Query().Get("diff"); d == "split" { 258 - diffOpts.Split = true 259 - } 260 - 261 - if !plumbing.IsHash(ref) { 262 - rp.pages.Error404(w) 263 - return 264 - } 265 - 266 - scheme := "http" 267 - if !rp.config.Core.Dev { 268 - scheme = "https" 269 - } 270 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 271 - xrpcc := &indigoxrpc.Client{ 272 - Host: host, 273 - } 274 - 275 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 276 - xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 277 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 278 - l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 279 - rp.pages.Error503(w) 280 - return 281 - } 282 - 283 - var result types.RepoCommitResponse 284 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 285 - l.Error("failed to decode XRPC response", "err", err) 286 - rp.pages.Error503(w) 287 - return 288 - } 289 - 290 - emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 291 - if err != nil { 292 - l.Error("failed to get email to did mapping", "err", err) 293 - } 294 - 295 - vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 296 - if err != nil { 297 - l.Error("failed to GetVerifiedCommits", "err", err) 298 - } 299 - 300 - user := rp.oauth.GetUser(r) 301 - repoInfo := f.RepoInfo(user) 302 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 303 - if err != nil { 304 - l.Error("failed to getPipelineStatuses", "err", err) 305 - // non-fatal 306 - } 307 - var pipeline *models.Pipeline 308 - if p, ok := pipelines[result.Diff.Commit.This]; ok { 309 - pipeline = &p 310 - } 311 - 312 - rp.pages.RepoCommit(w, pages.RepoCommitParams{ 313 - LoggedInUser: user, 314 - RepoInfo: f.RepoInfo(user), 315 - RepoCommitResponse: result, 316 - EmailToDid: emailToDidMap, 317 - VerifiedCommit: vc, 318 - Pipeline: pipeline, 319 - DiffOpts: diffOpts, 320 - }) 321 - } 322 - 323 - func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 324 - l := rp.logger.With("handler", "RepoTree") 325 - 326 - f, err := rp.repoResolver.Resolve(r) 327 - if err != nil { 328 - l.Error("failed to fully resolve repo", "err", err) 329 - return 330 - } 331 - 332 - ref := chi.URLParam(r, "ref") 333 - ref, _ = url.PathUnescape(ref) 334 - 335 - // if the tree path has a trailing slash, let's strip it 336 - // so we don't 404 337 - treePath := chi.URLParam(r, "*") 338 - treePath, _ = url.PathUnescape(treePath) 339 - treePath = strings.TrimSuffix(treePath, "/") 340 - 341 - scheme := "http" 342 - if !rp.config.Core.Dev { 343 - scheme = "https" 344 - } 345 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 346 - xrpcc := &indigoxrpc.Client{ 347 - Host: host, 348 - } 349 - 350 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 351 - xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 352 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 353 - l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 354 - rp.pages.Error503(w) 355 - return 356 - } 357 - 358 - // Convert XRPC response to internal types.RepoTreeResponse 359 - files := make([]types.NiceTree, len(xrpcResp.Files)) 360 - for i, xrpcFile := range xrpcResp.Files { 361 - file := types.NiceTree{ 362 - Name: xrpcFile.Name, 363 - Mode: xrpcFile.Mode, 364 - Size: int64(xrpcFile.Size), 365 - IsFile: xrpcFile.Is_file, 366 - IsSubtree: xrpcFile.Is_subtree, 367 - } 368 - 369 - // Convert last commit info if present 370 - if xrpcFile.Last_commit != nil { 371 - commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 372 - file.LastCommit = &types.LastCommitInfo{ 373 - Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 374 - Message: xrpcFile.Last_commit.Message, 375 - When: commitWhen, 376 - } 377 - } 378 - 379 - files[i] = file 380 - } 381 - 382 - result := types.RepoTreeResponse{ 383 - Ref: xrpcResp.Ref, 384 - Files: files, 385 - } 386 - 387 - if xrpcResp.Parent != nil { 388 - result.Parent = *xrpcResp.Parent 389 - } 390 - if xrpcResp.Dotdot != nil { 391 - result.DotDot = *xrpcResp.Dotdot 392 - } 393 - if xrpcResp.Readme != nil { 394 - result.ReadmeFileName = xrpcResp.Readme.Filename 395 - result.Readme = xrpcResp.Readme.Contents 396 - } 397 - 398 - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 399 - // so we can safely redirect to the "parent" (which is the same file). 400 - if len(result.Files) == 0 && result.Parent == treePath { 401 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 402 - http.Redirect(w, r, redirectTo, http.StatusFound) 403 - return 404 - } 405 - 406 - user := rp.oauth.GetUser(r) 407 - 408 - var breadcrumbs [][]string 409 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 410 - if treePath != "" { 411 - for idx, elem := range strings.Split(treePath, "/") { 412 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 413 - } 414 - } 415 - 416 - sortFiles(result.Files) 417 - 418 - rp.pages.RepoTree(w, pages.RepoTreeParams{ 419 - LoggedInUser: user, 420 - BreadCrumbs: breadcrumbs, 421 - TreePath: treePath, 422 - RepoInfo: f.RepoInfo(user), 423 - RepoTreeResponse: result, 424 - }) 425 - } 426 - 427 - func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 428 - l := rp.logger.With("handler", "RepoTags") 429 - 430 - f, err := rp.repoResolver.Resolve(r) 431 - if err != nil { 432 - l.Error("failed to get repo and knot", "err", err) 433 - return 434 - } 435 - 436 - scheme := "http" 437 - if !rp.config.Core.Dev { 438 - scheme = "https" 439 - } 440 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 441 - xrpcc := &indigoxrpc.Client{ 442 - Host: host, 443 - } 444 - 445 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 446 - xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 447 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 448 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 449 - rp.pages.Error503(w) 450 - return 451 - } 452 - 453 - var result types.RepoTagsResponse 454 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 455 - l.Error("failed to decode XRPC response", "err", err) 456 - rp.pages.Error503(w) 457 - return 458 - } 459 - 460 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 461 - if err != nil { 462 - l.Error("failed grab artifacts", "err", err) 463 - return 464 - } 465 - 466 - // convert artifacts to map for easy UI building 467 - artifactMap := make(map[plumbing.Hash][]models.Artifact) 468 - for _, a := range artifacts { 469 - artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 470 - } 471 - 472 - var danglingArtifacts []models.Artifact 473 - for _, a := range artifacts { 474 - found := false 475 - for _, t := range result.Tags { 476 - if t.Tag != nil { 477 - if t.Tag.Hash == a.Tag { 478 - found = true 479 - } 480 - } 481 - } 482 - 483 - if !found { 484 - danglingArtifacts = append(danglingArtifacts, a) 485 - } 486 - } 487 - 488 - user := rp.oauth.GetUser(r) 489 - rp.pages.RepoTags(w, pages.RepoTagsParams{ 490 - LoggedInUser: user, 491 - RepoInfo: f.RepoInfo(user), 492 - RepoTagsResponse: result, 493 - ArtifactMap: artifactMap, 494 - DanglingArtifacts: danglingArtifacts, 495 - }) 496 - } 497 - 498 - func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 499 - l := rp.logger.With("handler", "RepoBranches") 500 - 501 - f, err := rp.repoResolver.Resolve(r) 502 - if err != nil { 503 - l.Error("failed to get repo and knot", "err", err) 504 - return 505 - } 506 - 507 - scheme := "http" 508 - if !rp.config.Core.Dev { 509 - scheme = "https" 510 - } 511 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 512 - xrpcc := &indigoxrpc.Client{ 513 - Host: host, 514 - } 515 - 516 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 517 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 518 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 519 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 520 - rp.pages.Error503(w) 521 - return 522 - } 523 - 524 - var result types.RepoBranchesResponse 525 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 526 - l.Error("failed to decode XRPC response", "err", err) 527 - rp.pages.Error503(w) 528 - return 529 - } 530 - 531 - sortBranches(result.Branches) 532 - 533 - user := rp.oauth.GetUser(r) 534 - rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 535 - LoggedInUser: user, 536 - RepoInfo: f.RepoInfo(user), 537 - RepoBranchesResponse: result, 538 - }) 539 - } 540 - 541 - func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 542 - l := rp.logger.With("handler", "DeleteBranch") 543 - 544 - f, err := rp.repoResolver.Resolve(r) 545 - if err != nil { 546 - l.Error("failed to get repo and knot", "err", err) 547 - return 548 - } 549 - 550 - noticeId := "delete-branch-error" 551 - fail := func(msg string, err error) { 552 - l.Error(msg, "err", err) 553 - rp.pages.Notice(w, noticeId, msg) 554 - } 555 - 556 - branch := r.FormValue("branch") 557 - if branch == "" { 558 - fail("No branch provided.", nil) 559 - return 560 - } 561 - 562 - client, err := rp.oauth.ServiceClient( 563 - r, 564 - oauth.WithService(f.Knot), 565 - oauth.WithLxm(tangled.RepoDeleteBranchNSID), 566 - oauth.WithDev(rp.config.Core.Dev), 567 - ) 568 - if err != nil { 569 - fail("Failed to connect to knotserver", nil) 570 - return 571 - } 572 - 573 - err = tangled.RepoDeleteBranch( 574 - r.Context(), 575 - client, 576 - &tangled.RepoDeleteBranch_Input{ 577 - Branch: branch, 578 - Repo: f.RepoAt().String(), 579 - }, 580 - ) 581 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 582 - fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 583 - return 584 - } 585 - l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 586 - 587 - rp.pages.HxRefresh(w) 588 - } 589 - 590 - func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 591 - l := rp.logger.With("handler", "RepoBlob") 592 - 593 - f, err := rp.repoResolver.Resolve(r) 594 - if err != nil { 595 - l.Error("failed to get repo and knot", "err", err) 596 - return 597 - } 598 - 599 - ref := chi.URLParam(r, "ref") 600 - ref, _ = url.PathUnescape(ref) 601 - 602 - filePath := chi.URLParam(r, "*") 603 - filePath, _ = url.PathUnescape(filePath) 604 - 605 - scheme := "http" 606 - if !rp.config.Core.Dev { 607 - scheme = "https" 608 - } 609 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 610 - xrpcc := &indigoxrpc.Client{ 611 - Host: host, 612 - } 613 - 614 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 615 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 616 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 617 - l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 618 - rp.pages.Error503(w) 619 - return 620 - } 621 - 622 - // Use XRPC response directly instead of converting to internal types 623 - 624 - var breadcrumbs [][]string 625 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 626 - if filePath != "" { 627 - for idx, elem := range strings.Split(filePath, "/") { 628 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 629 - } 630 - } 631 - 632 - showRendered := false 633 - renderToggle := false 634 - 635 - if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 636 - renderToggle = true 637 - showRendered = r.URL.Query().Get("code") != "true" 638 - } 639 - 640 - var unsupported bool 641 - var isImage bool 642 - var isVideo bool 643 - var contentSrc string 644 - 645 - if resp.IsBinary != nil && *resp.IsBinary { 646 - ext := strings.ToLower(filepath.Ext(resp.Path)) 647 - switch ext { 648 - case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 649 - isImage = true 650 - case ".mp4", ".webm", ".ogg", ".mov", ".avi": 651 - isVideo = true 652 - default: 653 - unsupported = true 654 - } 655 - 656 - // fetch the raw binary content using sh.tangled.repo.blob xrpc 657 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 658 - 659 - baseURL := &url.URL{ 660 - Scheme: scheme, 661 - Host: f.Knot, 662 - Path: "/xrpc/sh.tangled.repo.blob", 663 - } 664 - query := baseURL.Query() 665 - query.Set("repo", repoName) 666 - query.Set("ref", ref) 667 - query.Set("path", filePath) 668 - query.Set("raw", "true") 669 - baseURL.RawQuery = query.Encode() 670 - blobURL := baseURL.String() 671 - 672 - contentSrc = blobURL 673 - if !rp.config.Core.Dev { 674 - contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 675 - } 676 - } 677 - 678 - lines := 0 679 - if resp.IsBinary == nil || !*resp.IsBinary { 680 - lines = strings.Count(resp.Content, "\n") + 1 681 - } 682 - 683 - var sizeHint uint64 684 - if resp.Size != nil { 685 - sizeHint = uint64(*resp.Size) 686 - } else { 687 - sizeHint = uint64(len(resp.Content)) 688 - } 689 - 690 - user := rp.oauth.GetUser(r) 691 - 692 - // Determine if content is binary (dereference pointer) 693 - isBinary := false 694 - if resp.IsBinary != nil { 695 - isBinary = *resp.IsBinary 696 - } 697 - 698 - rp.pages.RepoBlob(w, pages.RepoBlobParams{ 699 - LoggedInUser: user, 700 - RepoInfo: f.RepoInfo(user), 701 - BreadCrumbs: breadcrumbs, 702 - ShowRendered: showRendered, 703 - RenderToggle: renderToggle, 704 - Unsupported: unsupported, 705 - IsImage: isImage, 706 - IsVideo: isVideo, 707 - ContentSrc: contentSrc, 708 - RepoBlob_Output: resp, 709 - Contents: resp.Content, 710 - Lines: lines, 711 - SizeHint: sizeHint, 712 - IsBinary: isBinary, 713 - }) 714 - } 715 - 716 - func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 717 - l := rp.logger.With("handler", "RepoBlobRaw") 718 - 719 - f, err := rp.repoResolver.Resolve(r) 720 - if err != nil { 721 - l.Error("failed to get repo and knot", "err", err) 722 - w.WriteHeader(http.StatusBadRequest) 723 - return 724 - } 725 - 726 - ref := chi.URLParam(r, "ref") 727 - ref, _ = url.PathUnescape(ref) 728 - 729 - filePath := chi.URLParam(r, "*") 730 - filePath, _ = url.PathUnescape(filePath) 731 - 732 - scheme := "http" 733 - if !rp.config.Core.Dev { 734 - scheme = "https" 735 - } 736 - 737 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 738 - baseURL := &url.URL{ 739 - Scheme: scheme, 740 - Host: f.Knot, 741 - Path: "/xrpc/sh.tangled.repo.blob", 742 - } 743 - query := baseURL.Query() 744 - query.Set("repo", repo) 745 - query.Set("ref", ref) 746 - query.Set("path", filePath) 747 - query.Set("raw", "true") 748 - baseURL.RawQuery = query.Encode() 749 - blobURL := baseURL.String() 750 - 751 - req, err := http.NewRequest("GET", blobURL, nil) 752 - if err != nil { 753 - l.Error("failed to create request", "err", err) 754 - return 755 - } 756 - 757 - // forward the If-None-Match header 758 - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 759 - req.Header.Set("If-None-Match", clientETag) 760 - } 761 - 762 - client := &http.Client{} 763 - resp, err := client.Do(req) 764 - if err != nil { 765 - l.Error("failed to reach knotserver", "err", err) 766 - rp.pages.Error503(w) 767 - return 768 - } 769 - defer resp.Body.Close() 770 - 771 - // forward 304 not modified 772 - if resp.StatusCode == http.StatusNotModified { 773 - w.WriteHeader(http.StatusNotModified) 774 - return 775 - } 776 - 777 - if resp.StatusCode != http.StatusOK { 778 - l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 779 - w.WriteHeader(resp.StatusCode) 780 - _, _ = io.Copy(w, resp.Body) 781 - return 782 - } 783 - 784 - contentType := resp.Header.Get("Content-Type") 785 - body, err := io.ReadAll(resp.Body) 786 - if err != nil { 787 - l.Error("error reading response body from knotserver", "err", err) 788 - w.WriteHeader(http.StatusInternalServerError) 789 - return 790 - } 791 - 792 - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 793 - // serve all textual content as text/plain 794 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 795 - w.Write(body) 796 - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 797 - // serve images and videos with their original content type 798 - w.Header().Set("Content-Type", contentType) 799 - w.Write(body) 800 - } else { 801 - w.WriteHeader(http.StatusUnsupportedMediaType) 802 - w.Write([]byte("unsupported content type")) 803 - return 804 - } 805 - } 806 - 807 91 // isTextualMimeType returns true if the MIME type represents textual content 808 - // that should be served as text/plain 809 - func isTextualMimeType(mimeType string) bool { 810 - textualTypes := []string{ 811 - "application/json", 812 - "application/xml", 813 - "application/yaml", 814 - "application/x-yaml", 815 - "application/toml", 816 - "application/javascript", 817 - "application/ecmascript", 818 - "message/", 819 - } 820 - 821 - return slices.Contains(textualTypes, mimeType) 822 - } 823 92 824 93 // modify the spindle configured for this repo 825 94 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { ··· 935 1686 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 936 1687 } 937 1688 938 - func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { 939 - l := rp.logger.With("handler", "EditBaseSettings") 940 - 941 - noticeId := "repo-base-settings-error" 942 - 943 - f, err := rp.repoResolver.Resolve(r) 944 - if err != nil { 945 - l.Error("failed to get repo and knot", "err", err) 946 - w.WriteHeader(http.StatusBadRequest) 947 - return 948 - } 949 - 950 - client, err := rp.oauth.AuthorizedClient(r) 951 - if err != nil { 952 - l.Error("failed to get client") 953 - rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.") 954 - return 955 - } 956 - 957 - var ( 958 - description = r.FormValue("description") 959 - website = r.FormValue("website") 960 - topicStr = r.FormValue("topics") 961 - ) 962 - 963 - err = rp.validator.ValidateURI(website) 964 - if err != nil { 965 - l.Error("invalid uri", "err", err) 966 - rp.pages.Notice(w, noticeId, err.Error()) 967 - return 968 - } 969 - 970 - topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 971 - if err != nil { 972 - l.Error("invalid topics", "err", err) 973 - rp.pages.Notice(w, noticeId, err.Error()) 974 - return 975 - } 976 - l.Debug("got", "topicsStr", topicStr, "topics", topics) 977 - 978 - newRepo := f.Repo 979 - newRepo.Description = description 980 - newRepo.Website = website 981 - newRepo.Topics = topics 982 - record := newRepo.AsRecord() 983 - 984 - tx, err := rp.db.BeginTx(r.Context(), nil) 985 - if err != nil { 986 - l.Error("failed to begin transaction", "err", err) 987 - rp.pages.Notice(w, noticeId, "Failed to save repository information.") 988 - return 989 - } 990 - defer tx.Rollback() 991 - 992 - err = db.PutRepo(tx, newRepo) 993 - if err != nil { 994 - l.Error("failed to update repository", "err", err) 995 - rp.pages.Notice(w, noticeId, "Failed to save repository information.") 996 - return 997 - } 998 - 999 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1000 - if err != nil { 1001 - // failed to get record 1002 - l.Error("failed to get repo record", "err", err) 1003 - rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.") 1004 - return 1005 - } 1006 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1007 - Collection: tangled.RepoNSID, 1008 - Repo: newRepo.Did, 1009 - Rkey: newRepo.Rkey, 1010 - SwapRecord: ex.Cid, 1011 - Record: &lexutil.LexiconTypeDecoder{ 1012 - Val: &record, 1013 - }, 1014 - }) 1015 - 1016 - if err != nil { 1017 - l.Error("failed to perferom update-repo query", "err", err) 1018 - // failed to get record 1019 - rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.") 1020 - return 1021 - } 1022 - 1023 - err = tx.Commit() 1024 - if err != nil { 1025 - l.Error("failed to commit", "err", err) 1026 - } 1027 - 1028 - rp.pages.HxRefresh(w) 1029 - } 1030 - 1031 - func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1032 - l := rp.logger.With("handler", "SetDefaultBranch") 1033 - 1034 - f, err := rp.repoResolver.Resolve(r) 1035 - if err != nil { 1036 - l.Error("failed to get repo and knot", "err", err) 1037 - return 1038 - } 1039 - 1040 - noticeId := "operation-error" 1041 - branch := r.FormValue("branch") 1042 - if branch == "" { 1043 - http.Error(w, "malformed form", http.StatusBadRequest) 1044 - return 1045 - } 1046 - 1047 - client, err := rp.oauth.ServiceClient( 1048 - r, 1049 - oauth.WithService(f.Knot), 1050 - oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1051 - oauth.WithDev(rp.config.Core.Dev), 1052 - ) 1053 - if err != nil { 1054 - l.Error("failed to connect to knot server", "err", err) 1055 - rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1056 - return 1057 - } 1058 - 1059 - xe := tangled.RepoSetDefaultBranch( 1060 - r.Context(), 1061 - client, 1062 - &tangled.RepoSetDefaultBranch_Input{ 1063 - Repo: f.RepoAt().String(), 1064 - DefaultBranch: branch, 1065 - }, 1066 - ) 1067 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1068 - l.Error("xrpc failed", "err", xe) 1069 - rp.pages.Notice(w, noticeId, err.Error()) 1070 - return 1071 - } 1072 - 1073 - rp.pages.HxRefresh(w) 1074 - } 1075 - 1076 - func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1077 - user := rp.oauth.GetUser(r) 1078 - l := rp.logger.With("handler", "Secrets") 1079 - l = l.With("did", user.Did) 1080 - 1081 - f, err := rp.repoResolver.Resolve(r) 1082 - if err != nil { 1083 - l.Error("failed to get repo and knot", "err", err) 1084 - return 1085 - } 1086 - 1087 - if f.Spindle == "" { 1088 - l.Error("empty spindle cannot add/rm secret", "err", err) 1089 - return 1090 - } 1091 - 1092 - lxm := tangled.RepoAddSecretNSID 1093 - if r.Method == http.MethodDelete { 1094 - lxm = tangled.RepoRemoveSecretNSID 1095 - } 1096 - 1097 - spindleClient, err := rp.oauth.ServiceClient( 1098 - r, 1099 - oauth.WithService(f.Spindle), 1100 - oauth.WithLxm(lxm), 1101 - oauth.WithExp(60), 1102 - oauth.WithDev(rp.config.Core.Dev), 1103 - ) 1104 - if err != nil { 1105 - l.Error("failed to create spindle client", "err", err) 1106 - return 1107 - } 1108 - 1109 - key := r.FormValue("key") 1110 - if key == "" { 1111 - w.WriteHeader(http.StatusBadRequest) 1112 - return 1113 - } 1114 - 1115 - switch r.Method { 1116 - case http.MethodPut: 1117 - errorId := "add-secret-error" 1118 - 1119 - value := r.FormValue("value") 1120 - if value == "" { 1121 - w.WriteHeader(http.StatusBadRequest) 1122 - return 1123 - } 1124 - 1125 - err = tangled.RepoAddSecret( 1126 - r.Context(), 1127 - spindleClient, 1128 - &tangled.RepoAddSecret_Input{ 1129 - Repo: f.RepoAt().String(), 1130 - Key: key, 1131 - Value: value, 1132 - }, 1133 - ) 1134 - if err != nil { 1135 - l.Error("Failed to add secret.", "err", err) 1136 - rp.pages.Notice(w, errorId, "Failed to add secret.") 1137 - return 1138 - } 1139 - 1140 - case http.MethodDelete: 1141 - errorId := "operation-error" 1142 - 1143 - err = tangled.RepoRemoveSecret( 1144 - r.Context(), 1145 - spindleClient, 1146 - &tangled.RepoRemoveSecret_Input{ 1147 - Repo: f.RepoAt().String(), 1148 - Key: key, 1149 - }, 1150 - ) 1151 - if err != nil { 1152 - l.Error("Failed to delete secret.", "err", err) 1153 - rp.pages.Notice(w, errorId, "Failed to delete secret.") 1154 - return 1155 - } 1156 - } 1157 - 1158 - rp.pages.HxRefresh(w) 1159 - } 1160 - 1161 - type tab = map[string]any 1162 - 1163 - var ( 1164 - // would be great to have ordered maps right about now 1165 - settingsTabs []tab = []tab{ 1166 - {"Name": "general", "Icon": "sliders-horizontal"}, 1167 - {"Name": "access", "Icon": "users"}, 1168 - {"Name": "pipelines", "Icon": "layers-2"}, 1169 - } 1170 - ) 1171 - 1172 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1173 - tabVal := r.URL.Query().Get("tab") 1174 - if tabVal == "" { 1175 - tabVal = "general" 1176 - } 1177 - 1178 - switch tabVal { 1179 - case "general": 1180 - rp.generalSettings(w, r) 1181 - 1182 - case "access": 1183 - rp.accessSettings(w, r) 1184 - 1185 - case "pipelines": 1186 - rp.pipelineSettings(w, r) 1187 - } 1188 - } 1189 - 1190 - func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1191 - l := rp.logger.With("handler", "generalSettings") 1192 - 1193 - f, err := rp.repoResolver.Resolve(r) 1194 - user := rp.oauth.GetUser(r) 1195 - 1196 - scheme := "http" 1197 - if !rp.config.Core.Dev { 1198 - scheme = "https" 1199 - } 1200 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1201 - xrpcc := &indigoxrpc.Client{ 1202 - Host: host, 1203 - } 1204 - 1205 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1206 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1207 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1208 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1209 - rp.pages.Error503(w) 1210 - return 1211 - } 1212 - 1213 - var result types.RepoBranchesResponse 1214 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1215 - l.Error("failed to decode XRPC response", "err", err) 1216 - rp.pages.Error503(w) 1217 - return 1218 - } 1219 - 1220 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) 1221 - if err != nil { 1222 - l.Error("failed to fetch labels", "err", err) 1223 - rp.pages.Error503(w) 1224 - return 1225 - } 1226 - 1227 - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1228 - if err != nil { 1229 - l.Error("failed to fetch labels", "err", err) 1230 - rp.pages.Error503(w) 1231 - return 1232 - } 1233 - // remove default labels from the labels list, if present 1234 - defaultLabelMap := make(map[string]bool) 1235 - for _, dl := range defaultLabels { 1236 - defaultLabelMap[dl.AtUri().String()] = true 1237 - } 1238 - n := 0 1239 - for _, l := range labels { 1240 - if !defaultLabelMap[l.AtUri().String()] { 1241 - labels[n] = l 1242 - n++ 1243 - } 1244 - } 1245 - labels = labels[:n] 1246 - 1247 - subscribedLabels := make(map[string]struct{}) 1248 - for _, l := range f.Repo.Labels { 1249 - subscribedLabels[l] = struct{}{} 1250 - } 1251 - 1252 - // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 1253 - // if all default labels are subbed, show the "unsubscribe all" button 1254 - shouldSubscribeAll := false 1255 - for _, dl := range defaultLabels { 1256 - if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 1257 - // one of the default labels is not subscribed to 1258 - shouldSubscribeAll = true 1259 - break 1260 - } 1261 - } 1262 - 1263 - rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1264 - LoggedInUser: user, 1265 - RepoInfo: f.RepoInfo(user), 1266 - Branches: result.Branches, 1267 - Labels: labels, 1268 - DefaultLabels: defaultLabels, 1269 - SubscribedLabels: subscribedLabels, 1270 - ShouldSubscribeAll: shouldSubscribeAll, 1271 - Tabs: settingsTabs, 1272 - Tab: "general", 1273 - }) 1274 - } 1275 - 1276 - func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1277 - l := rp.logger.With("handler", "accessSettings") 1278 - 1279 - f, err := rp.repoResolver.Resolve(r) 1280 - user := rp.oauth.GetUser(r) 1281 - 1282 - repoCollaborators, err := f.Collaborators(r.Context()) 1283 - if err != nil { 1284 - l.Error("failed to get collaborators", "err", err) 1285 - } 1286 - 1287 - rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1288 - LoggedInUser: user, 1289 - RepoInfo: f.RepoInfo(user), 1290 - Tabs: settingsTabs, 1291 - Tab: "access", 1292 - Collaborators: repoCollaborators, 1293 - }) 1294 - } 1295 - 1296 - func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1297 - l := rp.logger.With("handler", "pipelineSettings") 1298 - 1299 - f, err := rp.repoResolver.Resolve(r) 1300 - user := rp.oauth.GetUser(r) 1301 - 1302 - // all spindles that the repo owner is a member of 1303 - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1304 - if err != nil { 1305 - l.Error("failed to fetch spindles", "err", err) 1306 - return 1307 - } 1308 - 1309 - var secrets []*tangled.RepoListSecrets_Secret 1310 - if f.Spindle != "" { 1311 - if spindleClient, err := rp.oauth.ServiceClient( 1312 - r, 1313 - oauth.WithService(f.Spindle), 1314 - oauth.WithLxm(tangled.RepoListSecretsNSID), 1315 - oauth.WithExp(60), 1316 - oauth.WithDev(rp.config.Core.Dev), 1317 - ); err != nil { 1318 - l.Error("failed to create spindle client", "err", err) 1319 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1320 - l.Error("failed to fetch secrets", "err", err) 1321 - } else { 1322 - secrets = resp.Secrets 1323 - } 1324 - } 1325 - 1326 - slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1327 - return strings.Compare(a.Key, b.Key) 1328 - }) 1329 - 1330 - var dids []string 1331 - for _, s := range secrets { 1332 - dids = append(dids, s.CreatedBy) 1333 - } 1334 - resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1335 - 1336 - // convert to a more manageable form 1337 - var niceSecret []map[string]any 1338 - for id, s := range secrets { 1339 - when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1340 - niceSecret = append(niceSecret, map[string]any{ 1341 - "Id": id, 1342 - "Key": s.Key, 1343 - "CreatedAt": when, 1344 - "CreatedBy": resolvedIdents[id].Handle.String(), 1345 - }) 1346 - } 1347 - 1348 - rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1349 - LoggedInUser: user, 1350 - RepoInfo: f.RepoInfo(user), 1351 - Tabs: settingsTabs, 1352 - Tab: "pipelines", 1353 - Spindles: spindles, 1354 - CurrentSpindle: f.Spindle, 1355 - Secrets: niceSecret, 1356 - }) 1357 - } 1358 - 1359 1689 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1360 1690 l := rp.logger.With("handler", "SyncRepoFork") 1361 1691 ··· 1215 2387 Rkey: rkey, 1216 2388 }) 1217 2389 return err 1218 - } 1219 - 1220 - func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1221 - l := rp.logger.With("handler", "RepoCompareNew") 1222 - 1223 - user := rp.oauth.GetUser(r) 1224 - f, err := rp.repoResolver.Resolve(r) 1225 - if err != nil { 1226 - l.Error("failed to get repo and knot", "err", err) 1227 - return 1228 - } 1229 - 1230 - scheme := "http" 1231 - if !rp.config.Core.Dev { 1232 - scheme = "https" 1233 - } 1234 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1235 - xrpcc := &indigoxrpc.Client{ 1236 - Host: host, 1237 - } 1238 - 1239 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1240 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1241 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1242 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1243 - rp.pages.Error503(w) 1244 - return 1245 - } 1246 - 1247 - var branchResult types.RepoBranchesResponse 1248 - if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 1249 - l.Error("failed to decode XRPC branches response", "err", err) 1250 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1251 - return 1252 - } 1253 - branches := branchResult.Branches 1254 - 1255 - sortBranches(branches) 1256 - 1257 - var defaultBranch string 1258 - for _, b := range branches { 1259 - if b.IsDefault { 1260 - defaultBranch = b.Name 1261 - } 1262 - } 1263 - 1264 - base := defaultBranch 1265 - head := defaultBranch 1266 - 1267 - params := r.URL.Query() 1268 - queryBase := params.Get("base") 1269 - queryHead := params.Get("head") 1270 - if queryBase != "" { 1271 - base = queryBase 1272 - } 1273 - if queryHead != "" { 1274 - head = queryHead 1275 - } 1276 - 1277 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1278 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1279 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 1280 - rp.pages.Error503(w) 1281 - return 1282 - } 1283 - 1284 - var tags types.RepoTagsResponse 1285 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 1286 - l.Error("failed to decode XRPC tags response", "err", err) 1287 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1288 - return 1289 - } 1290 - 1291 - repoinfo := f.RepoInfo(user) 1292 - 1293 - rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 1294 - LoggedInUser: user, 1295 - RepoInfo: repoinfo, 1296 - Branches: branches, 1297 - Tags: tags.Tags, 1298 - Base: base, 1299 - Head: head, 1300 - }) 1301 - } 1302 - 1303 - func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 1304 - l := rp.logger.With("handler", "RepoCompare") 1305 - 1306 - user := rp.oauth.GetUser(r) 1307 - f, err := rp.repoResolver.Resolve(r) 1308 - if err != nil { 1309 - l.Error("failed to get repo and knot", "err", err) 1310 - return 1311 - } 1312 - 1313 - var diffOpts types.DiffOpts 1314 - if d := r.URL.Query().Get("diff"); d == "split" { 1315 - diffOpts.Split = true 1316 - } 1317 - 1318 - // if user is navigating to one of 1319 - // /compare/{base}/{head} 1320 - // /compare/{base}...{head} 1321 - base := chi.URLParam(r, "base") 1322 - head := chi.URLParam(r, "head") 1323 - if base == "" && head == "" { 1324 - rest := chi.URLParam(r, "*") // master...feature/xyz 1325 - parts := strings.SplitN(rest, "...", 2) 1326 - if len(parts) == 2 { 1327 - base = parts[0] 1328 - head = parts[1] 1329 - } 1330 - } 1331 - 1332 - base, _ = url.PathUnescape(base) 1333 - head, _ = url.PathUnescape(head) 1334 - 1335 - if base == "" || head == "" { 1336 - l.Error("invalid comparison") 1337 - rp.pages.Error404(w) 1338 - return 1339 - } 1340 - 1341 - scheme := "http" 1342 - if !rp.config.Core.Dev { 1343 - scheme = "https" 1344 - } 1345 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1346 - xrpcc := &indigoxrpc.Client{ 1347 - Host: host, 1348 - } 1349 - 1350 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1351 - 1352 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1353 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1354 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1355 - rp.pages.Error503(w) 1356 - return 1357 - } 1358 - 1359 - var branches types.RepoBranchesResponse 1360 - if err := json.Unmarshal(branchBytes, &branches); err != nil { 1361 - l.Error("failed to decode XRPC branches response", "err", err) 1362 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1363 - return 1364 - } 1365 - 1366 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1367 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1368 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 1369 - rp.pages.Error503(w) 1370 - return 1371 - } 1372 - 1373 - var tags types.RepoTagsResponse 1374 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 1375 - l.Error("failed to decode XRPC tags response", "err", err) 1376 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1377 - return 1378 - } 1379 - 1380 - compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 1381 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1382 - l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 1383 - rp.pages.Error503(w) 1384 - return 1385 - } 1386 - 1387 - var formatPatch types.RepoFormatPatchResponse 1388 - if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 1389 - l.Error("failed to decode XRPC compare response", "err", err) 1390 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1391 - return 1392 - } 1393 - 1394 - var diff types.NiceDiff 1395 - if formatPatch.CombinedPatchRaw != "" { 1396 - diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 1397 - } else { 1398 - diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 1399 - } 1400 - 1401 - repoinfo := f.RepoInfo(user) 1402 - 1403 - rp.pages.RepoCompare(w, pages.RepoCompareParams{ 1404 - LoggedInUser: user, 1405 - RepoInfo: repoinfo, 1406 - Branches: branches.Branches, 1407 - Tags: tags.Tags, 1408 - Base: base, 1409 - Head: head, 1410 - Diff: &diff, 1411 - DiffOpts: diffOpts, 1412 - }) 1413 - 1414 2390 }
+14 -14
appview/repo/router.go
··· 9 9 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 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) 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 16 r.Route("/tree/{ref}", func(r chi.Router) { 17 - r.Get("/", rp.RepoIndex) 18 - r.Get("/*", rp.RepoTree) 17 + r.Get("/", rp.Index) 18 + r.Get("/*", rp.Tree) 19 19 }) 20 - r.Get("/commit/{ref}", rp.RepoCommit) 21 - r.Get("/branches", rp.RepoBranches) 20 + r.Get("/commit/{ref}", rp.Commit) 21 + r.Get("/branches", rp.Branches) 22 22 r.Delete("/branches", rp.DeleteBranch) 23 23 r.Route("/tags", func(r chi.Router) { 24 - r.Get("/", rp.RepoTags) 24 + r.Get("/", rp.Tags) 25 25 r.Route("/{tag}", func(r chi.Router) { 26 26 r.Get("/download/{file}", rp.DownloadArtifact) 27 27 ··· 37 37 }) 38 38 }) 39 39 }) 40 - r.Get("/blob/{ref}/*", rp.RepoBlob) 40 + r.Get("/blob/{ref}/*", rp.Blob) 41 41 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 42 42 43 43 // intentionally doesn't use /* as this isn't ··· 54 54 }) 55 55 56 56 r.Route("/compare", func(r chi.Router) { 57 - r.Get("/", rp.RepoCompareNew) // start an new comparison 57 + r.Get("/", rp.CompareNew) // start an new comparison 58 58 59 59 // we have to wildcard here since we want to support GitHub's compare syntax 60 60 // /compare/{ref1}...{ref2} 61 61 // for example: 62 62 // /compare/master...some/feature 63 63 // /compare/master...example.com:another/feature <- this is a fork 64 - r.Get("/{base}/{head}", rp.RepoCompare) 65 - r.Get("/*", rp.RepoCompare) 64 + r.Get("/{base}/{head}", rp.Compare) 65 + r.Get("/*", rp.Compare) 66 66 }) 67 67 68 68 // label panel in issues/pulls/discussions/tasks ··· 75 75 r.Group(func(r chi.Router) { 76 76 r.Use(middleware.AuthMiddleware(rp.oauth)) 77 77 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 78 - r.Get("/", rp.RepoSettings) 78 + r.Get("/", rp.Settings) 79 79 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings) 80 80 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 81 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 + }