1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/hex"
10 "fmt"
11 "io"
12 "slices"
13 "strconv"
14 "strings"
15
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18)
19
20// Browse are the read use cases over the Git data of a repository: refs,
21// trees, blobs, history and commits. Every method applies the access rule
22// first: a repository the principal may not read, or that does not exist,
23// answers domain.ErrNotFound before the Git reader is touched.
24type Browse struct {
25 meta ports.RepoMetaStore
26 reader ports.RepoReader
27}
28
29// NewBrowse builds the read use cases over their ports.
30func NewBrowse(meta ports.RepoMetaStore, reader ports.RepoReader) *Browse {
31 return &Browse{meta: meta, reader: reader}
32}
33
34// authorize returns nil if p may read the repository.
35func (b *Browse) authorize(ctx context.Context, p domain.Principal, name domain.RepoName) error {
36 repo, err := lookupRepo(ctx, b.meta, name)
37 if err != nil {
38 return err
39 }
40 if !canRead(p, repo) {
41 return domain.ErrNotFound
42 }
43 return nil
44}
45
46// ListRefsParams selects the refs to list.
47type ListRefsParams struct {
48 // Kinds restricts the result; empty means every kind.
49 Kinds []domain.RefKind
50 // PageSize is 0 for the default, and lowered to MaxPageSize.
51 PageSize int
52 // PageToken is the NextPageToken of the previous page, or empty.
53 PageToken string
54}
55
56// RefPage is a page of refs.
57type RefPage struct {
58 Refs []domain.Ref
59 // NextPageToken is empty on the last page.
60 NextPageToken string
61}
62
63func kindRank(k domain.RefKind) int {
64 switch k {
65 case domain.RefKindHead:
66 return 0
67 case domain.RefKindBranch:
68 return 1
69 }
70 return 2
71}
72
73// ListRefs lists HEAD, the branches and the tags, in that order, then by
74// name. The page token names the last ref of the previous page, so refs that
75// appear or disappear between two pages never shift the others.
76func (b *Browse) ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error) {
77 size, err := pageSize(params.PageSize)
78 if err != nil {
79 return RefPage{}, err
80 }
81 for _, k := range params.Kinds {
82 if k != domain.RefKindHead && k != domain.RefKindBranch && k != domain.RefKindTag {
83 return RefPage{}, fmt.Errorf("%w: %q", domain.ErrInvalidRefKind, k)
84 }
85 }
86 after, err := decodeRefToken(params.PageToken)
87 if err != nil {
88 return RefPage{}, err
89 }
90 if err := b.authorize(ctx, p, repo); err != nil {
91 return RefPage{}, err
92 }
93 all, err := b.reader.ListRefs(ctx, repo)
94 if err != nil {
95 return RefPage{}, err
96 }
97
98 var wanted []domain.Ref
99 for _, r := range all {
100 if len(params.Kinds) == 0 || slices.Contains(params.Kinds, r.Kind) {
101 wanted = append(wanted, r)
102 }
103 }
104 var page RefPage
105 page.Refs, page.NextPageToken = pageAfter(wanted, size,
106 func(r domain.Ref) bool { return after == nil || refAfter(r, *after) },
107 encodeRefToken)
108 return page, nil
109}
110
111// refAfter reports whether r sorts after the ref the token names.
112func refAfter(r domain.Ref, last refCursor) bool {
113 if kr, lr := kindRank(r.Kind), kindRank(last.kind); kr != lr {
114 return kr > lr
115 }
116 return r.Name > last.name
117}
118
119// refCursor is the decoded page token of ListRefs.
120type refCursor struct {
121 kind domain.RefKind
122 name string
123}
124
125const (
126 refTokenPrefix = "r1:"
127 commitTokenPrefix = "c1:"
128)
129
130func encodeRefToken(last domain.Ref) string {
131 return base64.RawURLEncoding.EncodeToString([]byte(refTokenPrefix + string(last.Kind) + ":" + last.Name))
132}
133
134// decodeRefToken returns nil for the empty token.
135func decodeRefToken(tok string) (*refCursor, error) {
136 if tok == "" {
137 return nil, nil
138 }
139 raw, ok := decodeToken(tok, refTokenPrefix)
140 if !ok {
141 return nil, fmt.Errorf("%w: refs", domain.ErrInvalidPageToken)
142 }
143 kind, name, ok := strings.Cut(raw, ":")
144 k := domain.RefKind(kind)
145 if !ok || name == "" || (k != domain.RefKindHead && k != domain.RefKindBranch && k != domain.RefKindTag) {
146 return nil, fmt.Errorf("%w: refs", domain.ErrInvalidPageToken)
147 }
148 return &refCursor{kind: k, name: name}, nil
149}
150
151// ResolveRevision turns a revision into the commit it designates.
152func (b *Browse) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
153 if err := b.authorize(ctx, p, repo); err != nil {
154 return domain.ResolvedRevision{}, err
155 }
156 return b.reader.ResolveRevision(ctx, repo, rev)
157}
158
159// ResolveRefPath splits a path such as "feature/x/dir/file" into the longest
160// leading ref and the path inside it.
161func (b *Browse) ResolveRefPath(ctx context.Context, p domain.Principal, repo domain.RepoName, path string) (domain.RefPath, error) {
162 if err := b.authorize(ctx, p, repo); err != nil {
163 return domain.RefPath{}, err
164 }
165 return b.reader.ResolveRefPath(ctx, repo, path)
166}
167
168// GetTree lists a directory at a revision. With includeLastCommit, entries get
169// their last commit, for at most domain.MaxLastCommitEntries of them.
170func (b *Browse) GetTree(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
171 if err := b.authorize(ctx, p, repo); err != nil {
172 return domain.Tree{}, err
173 }
174 return b.reader.GetTree(ctx, repo, rev, path, includeLastCommit)
175}
176
177// ListPaths lists the paths of every file and symbolic link of a revision, at
178// most domain.MaxPathEntries paths and domain.MaxPathBytes bytes of paths.
179func (b *Browse) ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error) {
180 if err := b.authorize(ctx, p, repo); err != nil {
181 return domain.PathList{}, err
182 }
183 return b.reader.ListPaths(ctx, repo, rev, domain.MaxPathEntries, domain.MaxPathBytes)
184}
185
186// OpenBlob returns the description of a file and a reader of its content. The
187// caller closes the reader.
188func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
189 if err := b.authorize(ctx, p, repo); err != nil {
190 return domain.BlobInfo{}, nil, err
191 }
192 return b.reader.OpenBlob(ctx, repo, rev, path)
193}
194
195// ListCommitsParams selects a page of history.
196type ListCommitsParams struct {
197 // Revision is where the history starts; the zero value is HEAD. Ignored
198 // when PageToken is set: the token pins the commit the first page started
199 // from, so every page walks the same history even if a branch moves.
200 Revision domain.Revision
201 // Path limits the history to the commits that changed it. It must be the
202 // same on every page of a listing.
203 Path domain.TreePath
204 // PageSize is 0 for the default, and lowered to MaxPageSize.
205 PageSize int
206 // PageToken is the NextPageToken of the previous page, or empty.
207 PageToken string
208}
209
210// CommitPage is a page of history, newest first.
211type CommitPage struct {
212 Commits []domain.Commit
213 // NextPageToken is empty on the last page.
214 NextPageToken string
215}
216
217// ListCommits returns a page of history. The page token holds the commit the
218// listing started from, how many commits were already returned and a digest
219// of the path; a token used with another path is invalid.
220func (b *Browse) ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (CommitPage, error) {
221 size, err := pageSize(params.PageSize)
222 if err != nil {
223 return CommitPage{}, err
224 }
225 q := ports.CommitQuery{Revision: params.Revision, Path: params.Path, Limit: size}
226 if params.PageToken != "" {
227 head, skip, err := decodeCommitToken(params.PageToken, params.Path)
228 if err != nil {
229 return CommitPage{}, err
230 }
231 q.Revision, q.Skip = domain.RevisionOf(head), skip
232 }
233 if err := b.authorize(ctx, p, repo); err != nil {
234 return CommitPage{}, err
235 }
236 res, err := b.reader.ListCommits(ctx, repo, q)
237 if err != nil {
238 return CommitPage{}, err
239 }
240 out := CommitPage{Commits: res.Commits}
241 if res.HasMore {
242 out.NextPageToken = encodeCommitToken(res.Head, q.Skip+len(res.Commits), params.Path)
243 }
244 return out, nil
245}
246
247func pathDigest(path domain.TreePath) string {
248 sum := sha256.Sum256([]byte(path.String()))
249 return hex.EncodeToString(sum[:6])
250}
251
252func encodeCommitToken(head domain.ObjectID, skip int, path domain.TreePath) string {
253 raw := commitTokenPrefix + head.String() + ":" + strconv.Itoa(skip) + ":" + pathDigest(path)
254 return base64.RawURLEncoding.EncodeToString([]byte(raw))
255}
256
257func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int, error) {
258 bad := fmt.Errorf("%w: commits", domain.ErrInvalidPageToken)
259 raw, ok := decodeToken(tok, commitTokenPrefix)
260 if !ok {
261 return domain.ObjectID{}, 0, bad
262 }
263 parts := strings.Split(raw, ":")
264 if len(parts) != 3 {
265 return domain.ObjectID{}, 0, bad
266 }
267 head, err := domain.ParseObjectID(parts[0])
268 skip, err2 := strconv.Atoi(parts[1])
269 if err != nil || err2 != nil || skip < 0 || skip > MaxCommitSkip || parts[2] != pathDigest(path) {
270 return domain.ObjectID{}, 0, bad
271 }
272 return head, skip, nil
273}
274
275// GetCommit returns a commit with its statistics and its diff against the
276// first parent, within the default diff limits.
277func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
278 if err := b.authorize(ctx, p, repo); err != nil {
279 return domain.CommitDetail{}, err
280 }
281 return b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())
282}