~bigbes/sr-ht-compare

sr-ht-compare/authz/authz.go -rw-r--r-- 6.2 KiB
9720ccc2 — bigbes go.mod: take the shared libraries' current heads 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
package authz

import (
	"context"
	"fmt"
	"strings"
	"sync"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-core/client"

	"sourcecraft.dev/bigbes/sr-ht-compare/core"
)

// RepoInfo is the subset of git.sr.ht repository metadata diff.sr.ht needs
// to render a page. It carries no ownership or ACL data: the mere fact that the
// authorizer returned a RepoInfo means the viewer is allowed to see the repo.
type RepoInfo struct {
	ID          int
	Name        string
	Description string
	Visibility  string
}

// Authorizer decides, for a given viewer, whether a repository may be seen and
// returns its metadata. A nil *RepoInfo is never returned alongside a nil
// error; an unauthorized or missing repo is reported as core.ErrNotFound so
// callers cannot distinguish "forbidden" from "absent".
type Authorizer interface {
	// Repo resolves a single repository owned by owner (with or without a
	// leading "~") as seen by viewer ("" for anonymous). A null user or null
	// repository yields core.ErrNotFound; transport/GraphQL failures yield a
	// wrapped error that is NOT core.ErrNotFound.
	Repo(ctx context.Context, viewer, owner, name string) (*RepoInfo, error)
	// MyRepos lists every repository owned by viewer. viewer must be non-empty.
	MyRepos(ctx context.Context, viewer string) ([]RepoInfo, error)
}

// maxMyRepos caps the number of repositories MyRepos will accumulate across
// pages, bounding memory and request count for pathological accounts.
const maxMyRepos = 500

type cacheEntry struct {
	info     *RepoInfo // nil when notFound
	notFound bool
	expiry   time.Time
}

// GQLAuthorizer implements Authorizer against git.sr.ht's internal GraphQL API
// via core-go's client.Do, memoizing Repo results in a TTL cache. It is safe
// for concurrent use.
type GQLAuthorizer struct {
	ttl time.Duration

	mu        sync.Mutex
	cache     map[string]cacheEntry
	lastSweep time.Time
}

// NewAuthorizer returns a GQLAuthorizer whose positive and not-found Repo
// results are cached for ttl (the caller supplies 60s in production).
func NewAuthorizer(ttl time.Duration) *GQLAuthorizer {
	return &GQLAuthorizer{
		ttl:   ttl,
		cache: make(map[string]cacheEntry),
	}
}

// repoQuery asks for one repository under a user; both user and repository come
// back null when the viewer may not see them. username is passed WITHOUT "~".
const repoQuery = `query($u:String!,$r:String!){user(username:$u){repository(name:$r){id name description visibility}}}`

func (a *GQLAuthorizer) Repo(ctx context.Context, viewer, owner, name string) (*RepoInfo, error) {
	owner = strings.TrimPrefix(owner, "~")
	key := cacheKey(viewer, owner, name)

	if info, notFound, ok := a.load(key); ok {
		if notFound {
			return nil, core.ErrNotFound
		}
		return info, nil
	}

	var result struct {
		User *struct {
			Repository *struct {
				ID          int    `json:"id"`
				Name        string `json:"name"`
				Description string `json:"description"`
				Visibility  string `json:"visibility"`
			} `json:"repository"`
		} `json:"user"`
	}
	query := client.GraphQLQuery{
		Query:     repoQuery,
		Variables: map[string]any{"u": owner, "r": name},
	}
	if err := client.Do(ctx, viewer, "git.sr.ht", query, &result); err != nil {
		// Transport or GraphQL error — do NOT cache and do NOT mask as
		// not-found; the web layer distinguishes 404 from 502.
		return nil, fmt.Errorf("git.sr.ht repository query for ~%s/%s: %w", owner, name, err)
	}
	if result.User == nil || result.User.Repository == nil {
		a.store(key, cacheEntry{notFound: true})
		return nil, core.ErrNotFound
	}

	repo := result.User.Repository
	info := &RepoInfo{
		ID:          repo.ID,
		Name:        repo.Name,
		Description: repo.Description,
		Visibility:  repo.Visibility,
	}
	a.store(key, cacheEntry{info: info})
	return info, nil
}

// myReposQuery paginates the viewer's own repositories via the cursor scalar.
const myReposQuery = `query($c:Cursor){me{repositories(cursor:$c){results{id name description visibility} cursor}}}`

func (a *GQLAuthorizer) MyRepos(ctx context.Context, viewer string) ([]RepoInfo, error) {
	if viewer == "" {
		return nil, fmt.Errorf("authz: MyRepos requires an authenticated viewer")
	}

	var repos []RepoInfo
	var cursor *string
	for {
		var result struct {
			Me struct {
				Repositories struct {
					Results []struct {
						ID          int    `json:"id"`
						Name        string `json:"name"`
						Description string `json:"description"`
						Visibility  string `json:"visibility"`
					} `json:"results"`
					Cursor *string `json:"cursor"`
				} `json:"repositories"`
			} `json:"me"`
		}
		query := client.GraphQLQuery{
			Query:     myReposQuery,
			Variables: map[string]any{"c": cursor},
		}
		if err := client.Do(ctx, viewer, "git.sr.ht", query, &result); err != nil {
			return nil, fmt.Errorf("git.sr.ht repositories query for ~%s: %w", viewer, err)
		}

		for _, r := range result.Me.Repositories.Results {
			repos = append(repos, RepoInfo{
				ID:          r.ID,
				Name:        r.Name,
				Description: r.Description,
				Visibility:  r.Visibility,
			})
			if len(repos) >= maxMyRepos {
				return repos, nil
			}
		}

		if result.Me.Repositories.Cursor == nil {
			break
		}
		cursor = result.Me.Repositories.Cursor
	}
	return repos, nil
}

func cacheKey(viewer, owner, name string) string {
	return viewer + "\x00" + owner + "\x00" + name
}

// load returns a cached entry if present and unexpired, pruning it lazily on a
// hit that has aged out.
func (a *GQLAuthorizer) load(key string) (info *RepoInfo, notFound, ok bool) {
	a.mu.Lock()
	defer a.mu.Unlock()
	e, exists := a.cache[key]
	if !exists {
		return nil, false, false
	}
	if time.Now().After(e.expiry) {
		delete(a.cache, key)
		return nil, false, false
	}
	return e.info, e.notFound, true
}

// store records an entry with a fresh expiry and opportunistically sweeps the
// whole map at most once per ttl, so no background goroutine is needed.
func (a *GQLAuthorizer) store(key string, e cacheEntry) {
	a.mu.Lock()
	defer a.mu.Unlock()
	now := time.Now()
	e.expiry = now.Add(a.ttl)
	if now.Sub(a.lastSweep) > a.ttl {
		for k, v := range a.cache {
			if now.After(v.expiry) {
				delete(a.cache, k)
			}
		}
		a.lastSweep = now
	}
	a.cache[key] = e
}

// compile-time assertion that GQLAuthorizer satisfies Authorizer.
var _ Authorizer = (*GQLAuthorizer)(nil)