This repository has no description
7.4 kB
266 lines
1package xrpc
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "net/http"
8 "path/filepath"
9 "time"
10 "unicode/utf8"
11
12 "github.com/bluesky-social/indigo/atproto/atclient"
13 "github.com/bluesky-social/indigo/atproto/syntax"
14
15 "github.com/go-git/go-git/v5/plumbing"
16 "github.com/go-git/go-git/v5/plumbing/object"
17 "tangled.org/core/api/tangled"
18 "tangled.org/core/gitutil"
19 "tangled.org/core/knotmirror/xrpc/gitea"
20)
21
22const (
23 LastCommitCache = "last_commit:%s:%s"
24 LastCommitCacheTTL = 30 * 24 * time.Hour
25 MaxReadmeBytes = 1 << 20
26)
27
28func (x *Xrpc) GetTree(w http.ResponseWriter, r *http.Request) {
29 var (
30 repoQuery = r.URL.Query().Get("repo")
31 ref = r.URL.Query().Get("ref") // ref can be empty (git.Open handles this)
32 path = r.URL.Query().Get("path") // path can be empty (defaults to root)
33 )
34 l := x.logger.With("method", "git.getTree", "repo", repoQuery, "ref", ref)
35 l.Debug("request")
36
37 repo, err := syntax.ParseDID(repoQuery)
38 if err != nil {
39 writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)})
40 return
41 }
42
43 var out *tangled.GitTempGetTree_Output
44 out, err = x.getTree(r.Context(), repo, ref, path)
45 if err != nil {
46 l.Warn("local mirror failed, trying proxy", "repo", repo, "err", err)
47 if x.proxyToKnot(w, r, repo) {
48 return
49 }
50 writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get tree"})
51 return
52 }
53 writeJson(w, http.StatusOK, out)
54}
55
56func (x *Xrpc) getTree(ctx context.Context, repo syntax.DID, ref, treePath string) (*tangled.GitTempGetTree_Output, error) {
57 repoPath, err := x.makeRepoPath(ctx, repo)
58 if err != nil {
59 return nil, fmt.Errorf("failed to resolve repo did: %w", err)
60 }
61 rev := ref
62 if rev == "" {
63 rev = "HEAD"
64 }
65
66 head, err := gitea.GetCommit(ctx, repoPath, rev)
67 if err != nil {
68 return nil, fmt.Errorf("get head commit: %w", err)
69 }
70
71 subRev := head.Hash.String() + "^{tree}"
72 if treePath != "" {
73 subRev = head.Hash.String() + ":" + treePath
74 }
75 subTree, err := gitea.GetTree(ctx, repoPath, subRev)
76 if err != nil {
77 return nil, fmt.Errorf("get subtree %s: %w", subRev, err)
78 }
79
80 entryPaths := make([]string, len(subTree.Entries)+1)
81 entryPaths[0] = ""
82 for i, entry := range subTree.Entries {
83 entryPaths[i+1] = entry.Name
84 }
85
86 commits, lastCommit, err := func(ctx context.Context, commit *object.Commit, treePath string, paths []string) (map[string]*object.Commit, *object.Commit, error) {
87 headRef := commit.Hash.String()
88
89 revs := make(map[string]string, len(paths))
90 var unHitPaths []string
91
92 keys := make([]string, len(paths))
93 for i, path := range paths {
94 keys[i] = fmt.Sprintf(LastCommitCache, headRef, filepath.Join(treePath, path))
95 }
96 if cached, err := x.rdb.MGet(ctx, keys...).Result(); err == nil {
97 for i, v := range cached {
98 if s, ok := v.(string); ok && s != "" {
99 revs[paths[i]] = s
100 } else {
101 unHitPaths = append(unHitPaths, paths[i])
102 }
103 }
104 } else {
105 unHitPaths = paths
106 }
107
108 if len(unHitPaths) > 0 {
109 commits, err := gitea.WalkGitLog(ctx, repoPath, headRef, treePath, unHitPaths...)
110 if err != nil {
111 return nil, nil, err
112 }
113 pipe := x.rdb.Pipeline()
114 for path, cid := range commits {
115 if cid == "" {
116 continue
117 }
118 revs[path] = cid
119 pipe.Set(ctx, fmt.Sprintf(LastCommitCache, headRef, filepath.Join(treePath, path)), cid, LastCommitCacheTTL)
120 }
121 if _, err := pipe.Exec(ctx); err != nil {
122 x.logger.Warn("git last-commit cache write failed", "err", err)
123 }
124 }
125
126 // start cat-file batch
127 batchWriter, batchReader, cancel := gitea.CatFileBatch(ctx, repoPath)
128 defer cancel()
129
130 // path -> commit map
131 commitsMap := map[string]*object.Commit{}
132 for path, commitId := range revs {
133 if commitId == headRef {
134 commitsMap[path] = commit
135 continue
136 }
137
138 if commitId == "" { // invalid commit?
139 continue
140 }
141
142 _, err := batchWriter.Write([]byte(commitId + "\n"))
143 if err != nil {
144 return nil, nil, err
145 }
146 _, typ, size, err := gitea.ReadBatchLine(batchReader)
147 if err != nil {
148 return nil, nil, err
149 }
150 if typ != "commit" {
151 if err := gitea.DiscardFull(batchReader, size+1); err != nil {
152 return nil, nil, err
153 }
154 return nil, nil, fmt.Errorf("unexpected type: %s for commit id: %s", typ, commitId)
155 }
156 c, err := gitea.ReadCommit(plumbing.NewHash(commitId), io.LimitReader(batchReader, size))
157 if _, err := batchReader.Discard(1); err != nil {
158 return nil, nil, err
159 }
160 commitsMap[path] = c
161 }
162
163 var treeCommit *object.Commit
164 if treePath == "" {
165 treeCommit = commit
166 } else if c, ok := commitsMap[""]; ok {
167 treeCommit = c
168 }
169
170 return commitsMap, treeCommit, nil
171 }(ctx, head, treePath, entryPaths)
172 if err != nil {
173 return nil, err
174 }
175
176 sizes, err := gitea.EntrySizes(ctx, repoPath, subTree.Entries)
177 if err != nil {
178 x.logger.Warn("tree entry size read failed", "err", err)
179 }
180
181 outEntries := make([]*tangled.GitTempGetTree_TreeEntry, len(subTree.Entries))
182 for i, entry := range subTree.Entries {
183 var entryLastCommit *tangled.GitTempGetTree_LastCommit
184 if commit, ok := commits[entry.Name]; ok {
185 entryLastCommit = &tangled.GitTempGetTree_LastCommit{
186 Hash: commit.Hash.String(),
187 Message: commit.Message,
188 When: commit.Author.When.Format(time.RFC3339),
189 Author: &tangled.GitTempGetTree_Signature{
190 Email: commit.Author.Email,
191 Name: commit.Author.Name,
192 },
193 }
194 }
195 outEntries[i] = &tangled.GitTempGetTree_TreeEntry{
196 Name: entry.Name,
197 Mode: entry.Mode.String(),
198 Size: sizes[i],
199 Last_commit: entryLastCommit,
200 }
201 }
202
203 var parent *string
204 var dotdot *string
205 if treePath != "" {
206 parent = &treePath
207 if dir := filepath.Dir(treePath); dir != "." {
208 dotdot = &dir
209 }
210 }
211
212 var outLastCommit *tangled.GitTempGetTree_LastCommit
213 if lastCommit != nil {
214 outLastCommit = &tangled.GitTempGetTree_LastCommit{
215 Hash: lastCommit.Hash.String(),
216 Message: lastCommit.Message,
217 When: lastCommit.Author.When.Format(time.RFC3339),
218 Author: &tangled.GitTempGetTree_Signature{
219 Email: lastCommit.Author.Email,
220 Name: lastCommit.Author.Name,
221 },
222 }
223 }
224
225 readmeName, readmeContents := x.readme(ctx, repoPath, subTree.Entries, sizes)
226
227 return &tangled.GitTempGetTree_Output{
228 Ref: ref,
229 Parent: parent,
230 Dotdot: dotdot,
231 Files: outEntries,
232 LastCommit: outLastCommit,
233 // TODO: remove this field entirely
234 Readme: &tangled.GitTempGetTree_Readme{
235 Filename: readmeName,
236 Contents: readmeContents,
237 },
238 }, nil
239}
240
241func (x *Xrpc) readme(ctx context.Context, repoPath string, entries []object.TreeEntry, sizes []int64) (string, string) {
242 for i, entry := range entries {
243 if !gitutil.IsReadmeFile(entry.Name, entry.Mode.String()) || sizes[i] > MaxReadmeBytes {
244 continue
245 }
246 size, reader, err := gitea.ReadBlob(ctx, repoPath, entry.Hash)
247 if err != nil {
248 x.logger.Warn("readme blob open failed", "file", entry.Name, "err", err)
249 continue
250 }
251 if size > MaxReadmeBytes {
252 reader.Close()
253 continue
254 }
255 contents, err := io.ReadAll(reader)
256 reader.Close()
257 if err != nil {
258 x.logger.Warn("readme blob read failed", "file", entry.Name, "err", err)
259 continue
260 }
261 if utf8.Valid(contents) {
262 return entry.Name, string(contents)
263 }
264 }
265 return "", ""
266}