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// OpenBlob returns the description of a file and a reader of its content. The
178// caller closes the reader.
179func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
180 if err := b.authorize(ctx, p, repo); err != nil {
181 return domain.BlobInfo{}, nil, err
182 }
183 return b.reader.OpenBlob(ctx, repo, rev, path)
184}
185
186// ListCommitsParams selects a page of history.
187type ListCommitsParams struct {
188 // Revision is where the history starts; the zero value is HEAD. Ignored
189 // when PageToken is set: the token pins the commit the first page started
190 // from, so every page walks the same history even if a branch moves.
191 Revision domain.Revision
192 // Path limits the history to the commits that changed it. It must be the
193 // same on every page of a listing.
194 Path domain.TreePath
195 // PageSize is 0 for the default, and lowered to MaxPageSize.
196 PageSize int
197 // PageToken is the NextPageToken of the previous page, or empty.
198 PageToken string
199}
200
201// CommitPage is a page of history, newest first.
202type CommitPage struct {
203 Commits []domain.Commit
204 // NextPageToken is empty on the last page.
205 NextPageToken string
206}
207
208// ListCommits returns a page of history. The page token holds the commit the
209// listing started from, how many commits were already returned and a digest
210// of the path; a token used with another path is invalid.
211func (b *Browse) ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (CommitPage, error) {
212 size, err := pageSize(params.PageSize)
213 if err != nil {
214 return CommitPage{}, err
215 }
216 q := ports.CommitQuery{Revision: params.Revision, Path: params.Path, Limit: size}
217 if params.PageToken != "" {
218 head, skip, err := decodeCommitToken(params.PageToken, params.Path)
219 if err != nil {
220 return CommitPage{}, err
221 }
222 q.Revision, q.Skip = domain.RevisionOf(head), skip
223 }
224 if err := b.authorize(ctx, p, repo); err != nil {
225 return CommitPage{}, err
226 }
227 res, err := b.reader.ListCommits(ctx, repo, q)
228 if err != nil {
229 return CommitPage{}, err
230 }
231 out := CommitPage{Commits: res.Commits}
232 if res.HasMore {
233 out.NextPageToken = encodeCommitToken(res.Head, q.Skip+len(res.Commits), params.Path)
234 }
235 return out, nil
236}
237
238func pathDigest(path domain.TreePath) string {
239 sum := sha256.Sum256([]byte(path.String()))
240 return hex.EncodeToString(sum[:6])
241}
242
243func encodeCommitToken(head domain.ObjectID, skip int, path domain.TreePath) string {
244 raw := commitTokenPrefix + head.String() + ":" + strconv.Itoa(skip) + ":" + pathDigest(path)
245 return base64.RawURLEncoding.EncodeToString([]byte(raw))
246}
247
248func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int, error) {
249 bad := fmt.Errorf("%w: commits", domain.ErrInvalidPageToken)
250 raw, ok := decodeToken(tok, commitTokenPrefix)
251 if !ok {
252 return domain.ObjectID{}, 0, bad
253 }
254 parts := strings.Split(raw, ":")
255 if len(parts) != 3 {
256 return domain.ObjectID{}, 0, bad
257 }
258 head, err := domain.ParseObjectID(parts[0])
259 skip, err2 := strconv.Atoi(parts[1])
260 if err != nil || err2 != nil || skip < 0 || skip > MaxCommitSkip || parts[2] != pathDigest(path) {
261 return domain.ObjectID{}, 0, bad
262 }
263 return head, skip, nil
264}
265
266// GetCommit returns a commit with its statistics and its diff against the
267// first parent, within the default diff limits.
268func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
269 if err := b.authorize(ctx, p, repo); err != nil {
270 return domain.CommitDetail{}, err
271 }
272 return b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())
273}