| 1 |
|
package authz |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"fmt" |
| 6 |
|
"strings" |
| 7 |
|
"sync" |
| 8 |
|
"time" |
| 9 |
|
|
| 10 |
|
"sourcecraft.dev/bigbes/sr-ht-core/client" |
| 11 |
|
|
| 12 |
|
"sourcecraft.dev/bigbes/sr-ht-compare/core" |
| 13 |
|
) |
| 14 |
|
|
| 15 |
|
// RepoInfo is the subset of git.sr.ht repository metadata diff.sr.ht needs |
| 16 |
|
// to render a page. It carries no ownership or ACL data: the mere fact that the |
| 17 |
|
// authorizer returned a RepoInfo means the viewer is allowed to see the repo. |
| 18 |
|
type RepoInfo struct { |
| 19 |
|
ID int |
| 20 |
|
Name string |
| 21 |
|
Description string |
| 22 |
|
Visibility string |
| 23 |
|
} |
| 24 |
|
|
| 25 |
|
// Authorizer decides, for a given viewer, whether a repository may be seen and |
| 26 |
|
// returns its metadata. A nil *RepoInfo is never returned alongside a nil |
| 27 |
|
// error; an unauthorized or missing repo is reported as core.ErrNotFound so |
| 28 |
|
// callers cannot distinguish "forbidden" from "absent". |
| 29 |
|
type Authorizer interface { |
| 30 |
|
// Repo resolves a single repository owned by owner (with or without a |
| 31 |
|
// leading "~") as seen by viewer ("" for anonymous). A null user or null |
| 32 |
|
// repository yields core.ErrNotFound; transport/GraphQL failures yield a |
| 33 |
|
// wrapped error that is NOT core.ErrNotFound. |
| 34 |
|
Repo(ctx context.Context, viewer, owner, name string) (*RepoInfo, error) |
| 35 |
|
// MyRepos lists every repository owned by viewer. viewer must be non-empty. |
| 36 |
|
MyRepos(ctx context.Context, viewer string) ([]RepoInfo, error) |
| 37 |
|
} |
| 38 |
|
|
| 39 |
|
// maxMyRepos caps the number of repositories MyRepos will accumulate across |
| 40 |
|
// pages, bounding memory and request count for pathological accounts. |
| 41 |
|
const maxMyRepos = 500 |
| 42 |
|
|
| 43 |
|
type cacheEntry struct { |
| 44 |
|
info *RepoInfo // nil when notFound |
| 45 |
|
notFound bool |
| 46 |
|
expiry time.Time |
| 47 |
|
} |
| 48 |
|
|
| 49 |
|
// GQLAuthorizer implements Authorizer against git.sr.ht's internal GraphQL API |
| 50 |
|
// via core-go's client.Do, memoizing Repo results in a TTL cache. It is safe |
| 51 |
|
// for concurrent use. |
| 52 |
|
type GQLAuthorizer struct { |
| 53 |
|
ttl time.Duration |
| 54 |
|
|
| 55 |
|
mu sync.Mutex |
| 56 |
|
cache map[string]cacheEntry |
| 57 |
|
lastSweep time.Time |
| 58 |
|
} |
| 59 |
|
|
| 60 |
|
// NewAuthorizer returns a GQLAuthorizer whose positive and not-found Repo |
| 61 |
|
// results are cached for ttl (the caller supplies 60s in production). |
| 62 |
13 |
func NewAuthorizer(ttl time.Duration) *GQLAuthorizer { |
| 63 |
13 |
return &GQLAuthorizer{ |
| 64 |
13 |
ttl: ttl, |
| 65 |
13 |
cache: make(map[string]cacheEntry), |
| 66 |
13 |
} |
| 67 |
13 |
} |
| 68 |
|
|
| 69 |
|
// repoQuery asks for one repository under a user; both user and repository come |
| 70 |
|
// back null when the viewer may not see them. username is passed WITHOUT "~". |
| 71 |
|
const repoQuery = `query($u:String!,$r:String!){user(username:$u){repository(name:$r){id name description visibility}}}` |
| 72 |
|
|
| 73 |
17 |
func (a *GQLAuthorizer) Repo(ctx context.Context, viewer, owner, name string) (*RepoInfo, error) { |
| 74 |
17 |
owner = strings.TrimPrefix(owner, "~") |
| 75 |
17 |
key := cacheKey(viewer, owner, name) |
| 76 |
17 |
|
| 77 |
17 |
if info, notFound, ok := a.load(key); ok { |
| 78 |
4 |
if notFound { |
| 79 |
2 |
return nil, core.ErrNotFound |
| 80 |
2 |
} |
| 81 |
2 |
return info, nil |
| 82 |
|
} |
| 83 |
|
|
| 84 |
13 |
var result struct { |
| 85 |
13 |
User *struct { |
| 86 |
13 |
Repository *struct { |
| 87 |
13 |
ID int `json:"id"` |
| 88 |
13 |
Name string `json:"name"` |
| 89 |
13 |
Description string `json:"description"` |
| 90 |
13 |
Visibility string `json:"visibility"` |
| 91 |
13 |
} `json:"repository"` |
| 92 |
13 |
} `json:"user"` |
| 93 |
13 |
} |
| 94 |
13 |
query := client.GraphQLQuery{ |
| 95 |
13 |
Query: repoQuery, |
| 96 |
13 |
Variables: map[string]any{"u": owner, "r": name}, |
| 97 |
13 |
} |
| 98 |
13 |
if err := client.Do(ctx, viewer, "git.sr.ht", query, &result); err != nil { |
| 99 |
2 |
// Transport or GraphQL error — do NOT cache and do NOT mask as |
| 100 |
2 |
// not-found; the web layer distinguishes 404 from 502. |
| 101 |
2 |
return nil, fmt.Errorf("git.sr.ht repository query for ~%s/%s: %w", owner, name, err) |
| 102 |
2 |
} |
| 103 |
11 |
if result.User == nil || result.User.Repository == nil { |
| 104 |
3 |
a.store(key, cacheEntry{notFound: true}) |
| 105 |
3 |
return nil, core.ErrNotFound |
| 106 |
3 |
} |
| 107 |
|
|
| 108 |
8 |
repo := result.User.Repository |
| 109 |
8 |
info := &RepoInfo{ |
| 110 |
8 |
ID: repo.ID, |
| 111 |
8 |
Name: repo.Name, |
| 112 |
8 |
Description: repo.Description, |
| 113 |
8 |
Visibility: repo.Visibility, |
| 114 |
8 |
} |
| 115 |
8 |
a.store(key, cacheEntry{info: info}) |
| 116 |
8 |
return info, nil |
| 117 |
|
} |
| 118 |
|
|
| 119 |
|
// myReposQuery paginates the viewer's own repositories via the cursor scalar. |
| 120 |
|
const myReposQuery = `query($c:Cursor){me{repositories(cursor:$c){results{id name description visibility} cursor}}}` |
| 121 |
|
|
| 122 |
4 |
func (a *GQLAuthorizer) MyRepos(ctx context.Context, viewer string) ([]RepoInfo, error) { |
| 123 |
4 |
if viewer == "" { |
| 124 |
1 |
return nil, fmt.Errorf("authz: MyRepos requires an authenticated viewer") |
| 125 |
1 |
} |
| 126 |
|
|
| 127 |
3 |
var repos []RepoInfo |
| 128 |
3 |
var cursor *string |
| 129 |
4 |
for { |
| 130 |
4 |
var result struct { |
| 131 |
4 |
Me struct { |
| 132 |
4 |
Repositories struct { |
| 133 |
4 |
Results []struct { |
| 134 |
4 |
ID int `json:"id"` |
| 135 |
4 |
Name string `json:"name"` |
| 136 |
4 |
Description string `json:"description"` |
| 137 |
4 |
Visibility string `json:"visibility"` |
| 138 |
4 |
} `json:"results"` |
| 139 |
4 |
Cursor *string `json:"cursor"` |
| 140 |
4 |
} `json:"repositories"` |
| 141 |
4 |
} `json:"me"` |
| 142 |
4 |
} |
| 143 |
4 |
query := client.GraphQLQuery{ |
| 144 |
4 |
Query: myReposQuery, |
| 145 |
4 |
Variables: map[string]any{"c": cursor}, |
| 146 |
4 |
} |
| 147 |
4 |
if err := client.Do(ctx, viewer, "git.sr.ht", query, &result); err != nil { |
| 148 |
0 |
return nil, fmt.Errorf("git.sr.ht repositories query for ~%s: %w", viewer, err) |
| 149 |
0 |
} |
| 150 |
|
|
| 151 |
5 |
for _, r := range result.Me.Repositories.Results { |
| 152 |
5 |
repos = append(repos, RepoInfo{ |
| 153 |
5 |
ID: r.ID, |
| 154 |
5 |
Name: r.Name, |
| 155 |
5 |
Description: r.Description, |
| 156 |
5 |
Visibility: r.Visibility, |
| 157 |
5 |
}) |
| 158 |
5 |
if len(repos) >= maxMyRepos { |
| 159 |
0 |
return repos, nil |
| 160 |
0 |
} |
| 161 |
|
} |
| 162 |
|
|
| 163 |
4 |
if result.Me.Repositories.Cursor == nil { |
| 164 |
3 |
break |
| 165 |
|
} |
| 166 |
1 |
cursor = result.Me.Repositories.Cursor |
| 167 |
|
} |
| 168 |
3 |
return repos, nil |
| 169 |
|
} |
| 170 |
|
|
| 171 |
17 |
func cacheKey(viewer, owner, name string) string { |
| 172 |
17 |
return viewer + "\x00" + owner + "\x00" + name |
| 173 |
17 |
} |
| 174 |
|
|
| 175 |
|
// load returns a cached entry if present and unexpired, pruning it lazily on a |
| 176 |
|
// hit that has aged out. |
| 177 |
17 |
func (a *GQLAuthorizer) load(key string) (info *RepoInfo, notFound, ok bool) { |
| 178 |
17 |
a.mu.Lock() |
| 179 |
17 |
defer a.mu.Unlock() |
| 180 |
17 |
e, exists := a.cache[key] |
| 181 |
17 |
if !exists { |
| 182 |
12 |
return nil, false, false |
| 183 |
12 |
} |
| 184 |
5 |
if time.Now().After(e.expiry) { |
| 185 |
1 |
delete(a.cache, key) |
| 186 |
1 |
return nil, false, false |
| 187 |
1 |
} |
| 188 |
4 |
return e.info, e.notFound, true |
| 189 |
|
} |
| 190 |
|
|
| 191 |
|
// store records an entry with a fresh expiry and opportunistically sweeps the |
| 192 |
|
// whole map at most once per ttl, so no background goroutine is needed. |
| 193 |
11 |
func (a *GQLAuthorizer) store(key string, e cacheEntry) { |
| 194 |
11 |
a.mu.Lock() |
| 195 |
11 |
defer a.mu.Unlock() |
| 196 |
11 |
now := time.Now() |
| 197 |
11 |
e.expiry = now.Add(a.ttl) |
| 198 |
11 |
if now.Sub(a.lastSweep) > a.ttl { |
| 199 |
10 |
for k, v := range a.cache { |
| 200 |
0 |
if now.After(v.expiry) { |
| 201 |
0 |
delete(a.cache, k) |
| 202 |
0 |
} |
| 203 |
|
} |
| 204 |
10 |
a.lastSweep = now |
| 205 |
|
} |
| 206 |
11 |
a.cache[key] = e |
| 207 |
|
} |
| 208 |
|
|
| 209 |
|
// compile-time assertion that GQLAuthorizer satisfies Authorizer. |
| 210 |
|
var _ Authorizer = (*GQLAuthorizer)(nil) |