368 lines
10 KiB
Go
368 lines
10 KiB
Go
package editor
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forge"
|
|
)
|
|
|
|
// Finding sites. With discover on for a platform, the editor looks through
|
|
// the repositories each signed-in account can reach and offers every one
|
|
// with a site.yaml at the top, beside the sites listed in editor.yaml. It
|
|
// reads only what the account could read anyway, and an operator who wants
|
|
// the editor limited to the sites they list leaves it off.
|
|
|
|
// DiscoverConfig turns finding sites on for a platform: `discover: true`, or
|
|
// a map to narrow it.
|
|
type DiscoverConfig struct {
|
|
Owners []string `yaml:"owners"` // only these users' and organizations' repositories; empty means any
|
|
Limit int `yaml:"limit"` // most recently updated repositories looked at per account; default 100
|
|
}
|
|
|
|
// UnmarshalYAML takes `discover: true` as well as the map form.
|
|
func (d *DiscoverConfig) UnmarshalYAML(n *yaml.Node) error {
|
|
if n.Kind == yaml.ScalarNode {
|
|
var on bool
|
|
if err := n.Decode(&on); err != nil {
|
|
return fmt.Errorf("discover: true, false, or a map with owners and limit")
|
|
}
|
|
if !on {
|
|
*d = DiscoverConfig{Limit: -1}
|
|
}
|
|
return nil
|
|
}
|
|
type plain DiscoverConfig
|
|
return n.Decode((*plain)(d))
|
|
}
|
|
|
|
func (d *DiscoverConfig) on() bool { return d != nil && d.Limit >= 0 }
|
|
|
|
func (d *DiscoverConfig) limit() int {
|
|
if d.Limit <= 0 {
|
|
return 100
|
|
}
|
|
return min(d.Limit, 1000)
|
|
}
|
|
|
|
const discoverTTL = 10 * time.Minute
|
|
|
|
type discovery struct {
|
|
sites []*SiteConfig
|
|
at time.Time
|
|
}
|
|
|
|
type discoverCache struct {
|
|
mu sync.Mutex
|
|
seen map[string]discovery
|
|
// running: one look through an account's repositories at a time, however
|
|
// many requests ask; the rest wait for its answer.
|
|
running map[string]*lookup
|
|
// slots bounds how many accounts are looked through at once.
|
|
slots chan struct{}
|
|
}
|
|
|
|
type lookup struct {
|
|
done chan struct{}
|
|
sites []*SiteConfig
|
|
err error
|
|
}
|
|
|
|
// platformRepo is what Gitea, Forgejo and GitHub say about a repository.
|
|
type platformRepo struct {
|
|
FullName string `json:"full_name"`
|
|
CloneURL string `json:"clone_url"`
|
|
DefaultBranch string `json:"default_branch"`
|
|
Archived bool `json:"archived"`
|
|
Empty bool `json:"empty"` // Gitea
|
|
Size int `json:"size"` // GitHub: 0 when empty
|
|
Owner struct {
|
|
Login string `json:"login"`
|
|
} `json:"owner"`
|
|
Permissions struct{ Pull, Push, Admin bool } `json:"permissions"`
|
|
}
|
|
|
|
// discovered returns the sites an account's repositories hold, from the
|
|
// cache when it's fresh.
|
|
func (s *Server) discovered(ctx context.Context, sess *Session) ([]*SiteConfig, error) {
|
|
f := s.cfg.forge(sess.Forge)
|
|
if f == nil || !f.Discover.on() || s.apiBase(f) == "" {
|
|
return nil, nil
|
|
}
|
|
sum := sha256.Sum256([]byte(sess.Token))
|
|
key := hex.EncodeToString(sum[:])
|
|
s.found.mu.Lock()
|
|
if d, ok := s.found.seen[key]; ok && time.Since(d.at) < discoverTTL {
|
|
s.found.mu.Unlock()
|
|
return d.sites, nil
|
|
}
|
|
if l, ok := s.found.running[key]; ok {
|
|
s.found.mu.Unlock()
|
|
select {
|
|
case <-l.done:
|
|
return l.sites, l.err
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
if s.found.running == nil {
|
|
s.found.running = map[string]*lookup{}
|
|
s.found.slots = make(chan struct{}, 8)
|
|
}
|
|
l := &lookup{done: make(chan struct{})}
|
|
s.found.running[key] = l
|
|
slots := s.found.slots
|
|
s.found.mu.Unlock()
|
|
defer func() {
|
|
s.found.mu.Lock()
|
|
delete(s.found.running, key)
|
|
s.found.mu.Unlock()
|
|
close(l.done)
|
|
}()
|
|
select {
|
|
case slots <- struct{}{}:
|
|
defer func() { <-slots }()
|
|
case <-ctx.Done():
|
|
l.err = ctx.Err()
|
|
return nil, l.err
|
|
}
|
|
l.sites, l.err = s.discover(ctx, f, sess, key)
|
|
return l.sites, l.err
|
|
}
|
|
|
|
// discover does the looking for discovered.
|
|
func (s *Server) discover(ctx context.Context, f *ForgeConfig, sess *Session, key string) ([]*SiteConfig, error) {
|
|
repos, err := s.listRepos(ctx, f, sess.Token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
owners := map[string]bool{}
|
|
for _, o := range f.Discover.Owners {
|
|
owners[strings.ToLower(o)] = true
|
|
}
|
|
var candidates []platformRepo
|
|
for _, r := range repos {
|
|
u, err := url.Parse(r.CloneURL)
|
|
switch {
|
|
// (Not GitHub's size: it's worked out later, and reads 0 for a while
|
|
// after the first push. An empty repository has no site.yaml anyway.)
|
|
case r.Archived, r.Empty, !r.Permissions.Pull, r.DefaultBranch == "":
|
|
case err != nil || !strings.EqualFold(u.Hostname(), f.Host): // only clone from the platform itself
|
|
case len(owners) > 0 && !owners[strings.ToLower(r.Owner.Login)]:
|
|
default:
|
|
candidates = append(candidates, r)
|
|
}
|
|
}
|
|
|
|
// Look for site.yaml in each, a few at a time.
|
|
type hit struct {
|
|
repo platformRepo
|
|
name string
|
|
}
|
|
hits := make([]*hit, len(candidates))
|
|
sem := make(chan struct{}, 6)
|
|
var wg sync.WaitGroup
|
|
for i, r := range candidates {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
if name, ok := s.siteYAML(ctx, f, sess.Token, r); ok {
|
|
hits[i] = &hit{r, name}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
var sites []*SiteConfig
|
|
for _, h := range hits {
|
|
if h == nil {
|
|
continue
|
|
}
|
|
name := h.name
|
|
if name == "" {
|
|
name = h.repo.FullName
|
|
}
|
|
// The id carries a short hash of the exact repository, so two whose
|
|
// names slug alike (acme/site, acme-site/_) never share one.
|
|
sum := sha256.Sum256([]byte(strings.ToLower(f.Host + "/" + h.repo.FullName)))
|
|
st := &SiteConfig{ID: slug(h.repo.FullName) + "-" + hex.EncodeToString(sum[:])[:6], Name: name, Repo: h.repo.CloneURL, Branch: h.repo.DefaultBranch, discovered: true}
|
|
sites = append(sites, st)
|
|
// The listing already says what this account may do here.
|
|
s.rememberAccess(sess.Token, st, Access{Read: h.repo.Permissions.Pull, Write: h.repo.Permissions.Push, Admin: h.repo.Permissions.Admin})
|
|
}
|
|
sort.Slice(sites, func(i, j int) bool { return strings.ToLower(sites[i].Name) < strings.ToLower(sites[j].Name) })
|
|
|
|
s.found.mu.Lock()
|
|
if s.found.seen == nil || len(s.found.seen) > 1024 {
|
|
s.found.seen = map[string]discovery{}
|
|
}
|
|
s.found.seen[key] = discovery{sites: sites, at: time.Now()}
|
|
s.found.mu.Unlock()
|
|
return sites, nil
|
|
}
|
|
|
|
// listRepos lists the repositories an account can reach, most recently
|
|
// updated first where the platform sorts, up to the configured limit.
|
|
func (s *Server) listRepos(ctx context.Context, f *ForgeConfig, token string) ([]platformRepo, error) {
|
|
base := s.apiBase(f)
|
|
limit := f.Discover.limit()
|
|
var out []platformRepo
|
|
for page := 1; len(out) < limit && page <= 20; page++ {
|
|
var u string
|
|
per := 50
|
|
if f.Kind == forge.GitHub {
|
|
per = 100
|
|
u = fmt.Sprintf("%s/user/repos?per_page=%d&page=%d&sort=updated&affiliation=owner,collaborator,organization_member", base, per, page)
|
|
} else {
|
|
u = fmt.Sprintf("%s/user/repos?limit=%d&page=%d", base, per, page)
|
|
}
|
|
var batch []platformRepo
|
|
if err := s.api(ctx, f, token, u, &batch); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, batch...)
|
|
if len(batch) < per {
|
|
break
|
|
}
|
|
}
|
|
if len(out) > limit {
|
|
out = out[:limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// siteYAML reports whether a repository has a HotDog CMS site.yaml at the
|
|
// top of its default branch, and the site's name from it.
|
|
func (s *Server) siteYAML(ctx context.Context, f *ForgeConfig, token string, r platformRepo) (string, bool) {
|
|
base := s.apiBase(f)
|
|
var u, accept string
|
|
if f.Kind == forge.GitHub {
|
|
u = fmt.Sprintf("%s/repos/%s/contents/site.yaml?ref=%s", base, r.FullName, url.QueryEscape(r.DefaultBranch))
|
|
accept = "application/vnd.github.raw+json"
|
|
} else {
|
|
u = fmt.Sprintf("%s/repos/%s/raw/site.yaml?ref=%s", base, r.FullName, url.QueryEscape(r.DefaultBranch))
|
|
accept = "text/plain"
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Accept", accept)
|
|
req.Header.Set("User-Agent", "hotdog-cms-editor")
|
|
res, err := s.http.Do(req)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return "", false
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(res.Body, 256<<10))
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
// Other tools use a file of that name too: it has to read like ours.
|
|
var cfg struct {
|
|
Name string `yaml:"name"`
|
|
URL string `yaml:"url"`
|
|
}
|
|
if yaml.Unmarshal(raw, &cfg) != nil || cfg.URL == "" {
|
|
return "", false
|
|
}
|
|
return strings.TrimSpace(cfg.Name), true
|
|
}
|
|
|
|
// allSites is every site the signed-in accounts can be offered: those in
|
|
// editor.yaml, then those found in the accounts' repositories. A repository
|
|
// listed in editor.yaml is never offered twice, and every site keeps an id
|
|
// no other has.
|
|
func (s *Server) allSites(ctx context.Context, accts []*Session) []*SiteConfig {
|
|
var out []*SiteConfig
|
|
repos := map[string]bool{}
|
|
ids := map[string]bool{}
|
|
for i := range s.cfg.Sites {
|
|
st := &s.cfg.Sites[i]
|
|
out = append(out, st)
|
|
repos[repoKey(st.Repo)] = true
|
|
ids[st.ID] = true
|
|
}
|
|
for _, a := range accts {
|
|
found, err := s.discovered(ctx, a)
|
|
if err != nil {
|
|
continue // a signed-out account is dealt with when it's used
|
|
}
|
|
for _, st := range found {
|
|
if repos[repoKey(st.Repo)] {
|
|
continue
|
|
}
|
|
repos[repoKey(st.Repo)] = true
|
|
c := *st
|
|
if ids[c.ID] {
|
|
u, _ := url.Parse(c.Repo)
|
|
c.ID = slug(c.ID + "-" + u.Hostname())
|
|
}
|
|
for n := 2; ids[c.ID]; n++ {
|
|
c.ID = fmt.Sprintf("%s-%d", slug(st.ID), n)
|
|
}
|
|
ids[c.ID] = true
|
|
out = append(out, &c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func repoKey(repo string) string {
|
|
return strings.TrimSuffix(strings.TrimSuffix(strings.ToLower(repo), "/"), ".git")
|
|
}
|
|
|
|
// findSite looks a site up by id among everything the accounts are offered.
|
|
// An id it doesn't know may be a repository made since the accounts were
|
|
// last looked through, so it looks again, at most every 30 seconds.
|
|
func (s *Server) findSite(ctx context.Context, accts []*Session, id string) *SiteConfig {
|
|
if st := s.cfg.site(id); st != nil {
|
|
return st
|
|
}
|
|
for try := 0; try < 2; try++ {
|
|
for _, st := range s.allSites(ctx, accts) {
|
|
if st.ID == id {
|
|
return st
|
|
}
|
|
}
|
|
if try == 0 && !s.forgetStale(accts, 30*time.Second) {
|
|
break
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// forgetStale drops the accounts' found sites older than age, and reports
|
|
// whether it dropped any.
|
|
func (s *Server) forgetStale(accts []*Session, age time.Duration) bool {
|
|
s.found.mu.Lock()
|
|
defer s.found.mu.Unlock()
|
|
dropped := false
|
|
for _, a := range accts {
|
|
sum := sha256.Sum256([]byte(a.Token))
|
|
key := hex.EncodeToString(sum[:])
|
|
if d, ok := s.found.seen[key]; ok && time.Since(d.at) > age {
|
|
delete(s.found.seen, key)
|
|
dropped = true
|
|
}
|
|
}
|
|
return dropped
|
|
}
|