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)