1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "strings"
10 "time"
11
12 "github.com/bnema/zerowrap"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18// pushRetries bounds the retries of a metadata update that loses a race.
19const pushRetries = 5
20
21// MaintenanceScheduler queues repository maintenance. *Maintainer fits.
22type MaintenanceScheduler interface {
23 Schedule(ctx context.Context, name domain.RepoName)
24}
25
26// Revalidator confirms that a principal obtained earlier is still
27// authorized. *Auth fits.
28type Revalidator interface {
29 Revalidate(ctx context.Context, p domain.Principal) error
30}
31
32// Pushes are the use cases around a Git push.
33type Pushes struct {
34 meta ports.RepoMetaStore
35 repos *Repos
36 git ports.GitRepos
37 bus ports.EventBus
38 clock ports.Clock
39 maint MaintenanceScheduler
40 auth Revalidator
41 fresh ports.RepoFreshener // nil: the reader needs no telling
42}
43
44// NewPushes builds the push use cases. Every argument is required: auth
45// re-validates the token of a push when its pre-receive hook runs, maint
46// queues the maintenance that follows a push.
47func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus ports.EventBus, clock ports.Clock, maint MaintenanceScheduler, auth Revalidator) *Pushes {
48 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
49}
50
51// WithFreshener makes Record tell the reader that a repository changed, so that
52// it serves the push at once. Call it before the first push; it returns u.
53func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes {
54 u.fresh = f
55 return u
56}
57
58// Open decides whether the principal may push to the repository and, with
59// create, creates it when it does not exist yet (private, published as
60// RepoCreated by Repos.Create). create is for the first request of a push only.
61//
62// Anonymous callers get domain.ErrUnauthenticated. A repository the caller
63// cannot read and cannot write, or a missing one it may not create, answers
64// domain.ErrNotFound, so that a private repository never shows itself. One it
65// can read but not write answers domain.ErrForbidden.
66func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error {
67 if p.IsAnonymous() {
68 return domain.ErrUnauthenticated
69 }
70 repo, err := u.meta.Get(ctx, name)
71 switch {
72 case err == nil:
73 return openExisting(p, repo)
74 case !errors.Is(err, domain.ErrNotFound):
75 return fmt.Errorf("get repository %q: %w", name, err)
76 }
77
78 if !create || !canWrite(p, name) {
79 return domain.ErrNotFound
80 }
81 _, err = u.repos.Create(ctx, p, CreateParams{Name: name, Visibility: domain.VisibilityPrivate})
82 switch {
83 case err == nil:
84 return nil // Create published RepoCreated
85 case errors.Is(err, domain.ErrAlreadyExists):
86 // A concurrent first push won the race: this one pushes to its repository.
87 repo, gerr := u.meta.Get(ctx, name)
88 if gerr != nil {
89 return fmt.Errorf("get repository %q: %w", name, gerr)
90 }
91 return openExisting(p, repo)
92 }
93 return err
94}
95
96func openExisting(p domain.Principal, repo domain.Repo) error {
97 switch {
98 case canWrite(p, repo.Name):
99 return nil
100 case canRead(p, repo):
101 return domain.ErrForbidden
102 }
103 return domain.ErrNotFound
104}
105
106// Check is the pre-receive decision. v1 accepts every push of a principal that
107// may write the repository; protected branches and limits will refuse here by
108// returning an error that matches domain.ErrRejected.
109//
110// The token of the push was valid when the request began, but a push can run
111// for long: it is re-validated here, so a token revoked or expired meanwhile
112// stops the push before Git applies it. A failure to tell refuses it too.
113func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
114 if err := u.auth.Revalidate(ctx, p); err != nil {
115 if errors.Is(err, domain.ErrUnauthenticated) {
116 return fmt.Errorf("%w: the token is no longer valid", domain.ErrRejected)
117 }
118 return fmt.Errorf("revalidate token: %w", err)
119 }
120 if !canWrite(p, name) {
121 return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
122 }
123 log := zerowrap.FromCtx(ctx)
124 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
125 return nil
126}
127
128// Record is the post-receive step of an accepted push: it sets pushed_at,
129// repairs HEAD when it points to a missing branch, tells the reader that the
130// repository changed, publishes a PushEvent and schedules maintenance. Every
131// step runs even when an earlier one fails; the failures are returned together
132// and the push itself is not affected.
133func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
134 log := zerowrap.FromCtx(ctx)
135 now := u.clock.Now().UTC()
136 var errs []error
137
138 repo, err := touchPushedAt(ctx, u.meta, name, now)
139 if err != nil {
140 errs = append(errs, err)
141 }
142 if err := u.fixDefaultBranch(ctx, name, updates); err != nil {
143 errs = append(errs, err)
144 }
145 if u.fresh != nil {
146 u.fresh.Refresh(ctx, name)
147 }
148 u.bus.Publish(ctx, domain.PushEvent{
149 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
150 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
151 })
152 u.maint.Schedule(ctx, name)
153 log.Info().
154 Str("repo", name.String()).Int("refs", len(updates)).
155 Str("principal", p.Label()).Str("token_id", p.TokenID()).
156 Msg("push received")
157 return errors.Join(errs...)
158}
159
160// touchPushedAt stores pushed_at, retrying when another write got in between.
161// A repository deleted meanwhile is not an error. It returns the repository
162// as read, or the zero value when it is unknown. A push (Pushes.Record) and a
163// merge, which moves a branch too, both record through it.
164func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
165 for range pushRetries {
166 repo, err := meta.Get(ctx, name)
167 if errors.Is(err, domain.ErrNotFound) {
168 return domain.Repo{}, nil
169 }
170 if err != nil {
171 return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
172 }
173 if !now.After(repo.PushedAt) {
174 return repo, nil // a later push already recorded
175 }
176 repo.PushedAt = now
177 err = meta.Update(ctx, repo)
178 if errors.Is(err, domain.ErrConflict) {
179 continue
180 }
181 if errors.Is(err, domain.ErrNotFound) {
182 return domain.Repo{}, nil
183 }
184 if err != nil {
185 return repo, fmt.Errorf("update repository %q: %w", name, err)
186 }
187 return repo, nil
188 }
189 return domain.Repo{}, fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict)
190}
191
192// fixDefaultBranch points HEAD to a branch that exists when it points to a
193// missing one: main when it was pushed, else the first pushed branch.
194func (u *Pushes) fixDefaultBranch(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) error {
195 branch, exists, err := u.git.DefaultBranch(ctx, name)
196 if errors.Is(err, domain.ErrNotFound) {
197 return nil // deleted meanwhile
198 }
199 if err != nil {
200 return fmt.Errorf("read default branch of %q: %w", name, err)
201 }
202 if exists {
203 return nil
204 }
205 target := ""
206 for _, up := range updates {
207 b, ok := strings.CutPrefix(up.Ref, "refs/heads/")
208 if !ok || up.IsDelete() {
209 continue
210 }
211 if b == "main" {
212 target = b
213 break
214 }
215 if target == "" {
216 target = b
217 }
218 }
219 if target == "" || target == branch {
220 return nil
221 }
222 err = u.git.SetDefaultBranch(ctx, name, target)
223 if errors.Is(err, domain.ErrNotFound) {
224 return nil // deleted meanwhile
225 }
226 if err != nil {
227 return fmt.Errorf("set default branch of %q to %q: %w", name, target, err)
228 }
229 log := zerowrap.FromCtx(ctx)
230 log.Info().Str("repo", name.String()).Str("branch", target).Msg("default branch set")
231 return nil
232}