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)