1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "encoding/base64"
8 "fmt"
9 "slices"
10 "strconv"
11 "strings"
12
13 "bnema.dev/ermite/internal/domain"
14 "bnema.dev/ermite/internal/ports"
15)
16
17const (
18 repoTokenPrefix = "rp1:"
19 tokenTokenPrefix = "tp1:"
20
21 // maxTokenLen bounds a page token before it is decoded.
22 maxTokenLen = 1024
23)
24
25// Page sizes of the listings.
26const (
27 // DefaultPageSize is the page size when the caller gives none.
28 DefaultPageSize = 50
29 // MaxPageSize is the largest page; a larger request is lowered to it.
30 MaxPageSize = 100
31 // MaxRepoPageSize is the largest page of ListRepos. A repository list is
32 // one scan of the metadata whatever the page, so a caller that wants the
33 // whole list (the web pages) asks for it in one page rather than in
34 // several scans.
35 MaxRepoPageSize = 1000
36 // MaxRefPageSize is the largest page of ListRefs, for the same reason:
37 // each page of refs runs the whole for-each-ref again.
38 MaxRefPageSize = 1000
39)
40
41// MaxCommitSkip bounds how deep into a history a page token may point: each
42// page walks the history from its start with `git log --skip`.
43const MaxCommitSkip = 100_000
44
45// pageSize applies the default and the maximum.
46func pageSize(n int) (int, error) { return pageSizeUpTo(n, MaxPageSize) }
47
48// pageSizeUpTo applies the default and the given maximum.
49func pageSizeUpTo(n, most int) (int, error) {
50 switch {
51 case n < 0:
52 return 0, fmt.Errorf("%w: %d", domain.ErrInvalidPageSize, n)
53 case n == 0:
54 return DefaultPageSize, nil
55 }
56 return min(n, most), nil
57}
58
59func decodeToken(tok, prefix string) (string, bool) {
60 if len(tok) > maxTokenLen {
61 return "", false
62 }
63 raw, err := base64.RawURLEncoding.DecodeString(tok)
64 if err != nil {
65 return "", false
66 }
67 return strings.CutPrefix(string(raw), prefix)
68}
69
70// pageAfter returns the first size items of the ordered items that satisfy
71// after (nil keeps all of them) and, when more follow, the token of the last
72// one returned. Tokens name the last item, so items that appear or disappear
73// between two pages never shift the others.
74func pageAfter[T any](items []T, size int, after func(T) bool, token func(T) string) (page []T, next string) {
75 for _, it := range items {
76 if after != nil && !after(it) {
77 continue
78 }
79 if len(page) == size {
80 return page, token(page[size-1])
81 }
82 page = append(page, it)
83 }
84 return page, ""
85}
86
87// ListPage returns a page of the repositories the principal may read, ordered
88// by name. size is 0 for DefaultPageSize and lowered to MaxRepoPageSize; the page
89// token names the last repository of the previous page, so repositories that
90// appear or disappear between two pages never shift the others.
91func (r *Repos) ListPage(ctx context.Context, p domain.Principal, size int, token string) (ports.RepoPage, error) {
92 n, err := pageSizeUpTo(size, MaxRepoPageSize)
93 if err != nil {
94 return ports.RepoPage{}, err
95 }
96 after, err := decodeRepoToken(token)
97 if err != nil {
98 return ports.RepoPage{}, err
99 }
100 all, err := r.List(ctx, p)
101 if err != nil {
102 return ports.RepoPage{}, err
103 }
104 var page ports.RepoPage
105 page.Repos, page.NextPageToken = pageAfter(all, n,
106 func(repo domain.Repo) bool { return after == "" || repo.Name.String() > after },
107 func(repo domain.Repo) string { return encodeRepoToken(repo.Name) })
108 return page, nil
109}
110
111func encodeRepoToken(last domain.RepoName) string {
112 return base64.RawURLEncoding.EncodeToString([]byte(repoTokenPrefix + last.String()))
113}
114
115// decodeRepoToken returns "" for the empty token.
116func decodeRepoToken(tok string) (string, error) {
117 if tok == "" {
118 return "", nil
119 }
120 raw, ok := decodeToken(tok, repoTokenPrefix)
121 if !ok || raw == "" {
122 return "", fmt.Errorf("%w: repositories", domain.ErrInvalidPageToken)
123 }
124 return raw, nil
125}
126
127// ListPage returns a page of the tokens List returns, newest first (then by
128// id). size and the page token work like Repos.ListPage; the token names the
129// last token of the previous page.
130func (t *Tokens) ListPage(ctx context.Context, p domain.Principal, size int, token string) (ports.TokenPage, error) {
131 n, err := pageSize(size)
132 if err != nil {
133 return ports.TokenPage{}, err
134 }
135 after, err := decodeTokenCursor(token)
136 if err != nil {
137 return ports.TokenPage{}, err
138 }
139 all, err := t.List(ctx, p)
140 if err != nil {
141 return ports.TokenPage{}, err
142 }
143 slices.SortFunc(all, func(a, b domain.Token) int {
144 if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
145 return c
146 }
147 return strings.Compare(b.ID, a.ID)
148 })
149 var page ports.TokenPage
150 page.Tokens, page.NextPageToken = pageAfter(all, n,
151 func(tok domain.Token) bool { return after == nil || tokenAfter(tok, *after) },
152 encodeTokenCursor)
153 return page, nil
154}
155
156type tokenCursor struct {
157 created int64
158 id string
159}
160
161// tokenAfter reports whether tok comes after the cursor in newest-first order.
162func tokenAfter(tok domain.Token, c tokenCursor) bool {
163 if created := tok.CreatedAt.UnixNano(); created != c.created {
164 return created < c.created
165 }
166 return tok.ID < c.id
167}
168
169func encodeTokenCursor(last domain.Token) string {
170 raw := tokenTokenPrefix + strconv.FormatInt(last.CreatedAt.UnixNano(), 10) + ":" + last.ID
171 return base64.RawURLEncoding.EncodeToString([]byte(raw))
172}
173
174// decodeTokenCursor returns nil for the empty token.
175func decodeTokenCursor(tok string) (*tokenCursor, error) {
176 if tok == "" {
177 return nil, nil
178 }
179 bad := fmt.Errorf("%w: tokens", domain.ErrInvalidPageToken)
180 raw, ok := decodeToken(tok, tokenTokenPrefix)
181 if !ok {
182 return nil, bad
183 }
184 created, id, ok := strings.Cut(raw, ":")
185 n, err := strconv.ParseInt(created, 10, 64)
186 if !ok || err != nil || id == "" {
187 return nil, bad
188 }
189 return &tokenCursor{created: n, id: id}, nil
190}