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