261101.2 · de64bda0325b4caac4af6191ee067279fdfc3a5a
web.go splits by purpose
web.go held forge URLs, cloning, the disk cache, listings and the pages of the web address. Each now has its own file: forge.go, webclone.go, webdisk.go, weblist.go and webpage.go. web.go keeps the web type, jobs, request dispatch and the helpers they share.
Declarations moved unchanged, as a sorted comparison of every line outside the import blocks confirms.
reading declarations
Changes to weblist.go. All files
@@ -0,0 +1,212 @@+package main++import (+ "bufio"+ "encoding/json"+ "errors"+ "io"+ "net/http"+ "net/url"+ "regexp"+ "slices"+ "strconv"+ "strings"+ "time"+)++// listing is the repos a host or account holds, and what told.+type listing struct {+ at time.Time+ source string // what answered, eg "the GitHub API"+ asked string // the URL asked+ failed []string // the sources that failed before it, and why+ repos []listed+}++type listed struct{ key, desc string }++func (l *listing) fresh() time.Duration {+ if len(l.repos) == 0 {+ return missFresh+ }+ return listFresh+}++// list lists the path key, by the first source that answers: the forge's+// API, a web frontend's index, then the cache.+func (w *web) list(j *webJob, key string, probed bool) {+ segs := strings.Split(key, "/")+ l := &listing{at: time.Now()}+ for _, src := range listSources(segs) {+ w.step(j, "GET "+src.url+", "+src.name)+ repos, err := w.ask(src)+ if err == nil && len(repos) > 0 {+ l.source, l.asked, l.repos = src.name, src.url, repos+ break+ }+ if err == nil {+ err = errors.New("no repos")+ }+ l.failed = append(l.failed, src.name+": "+err.Error())+ w.step(j, src.name+": "+err.Error())+ }+ // A stranger must not learn what others read, so a public server+ // never lists the cache.+ if l.source == "" && !w.public {+ l.source = "the cache"+ for _, c := range w.onDisk() {+ if strings.HasPrefix(c.key, key+"/") {+ l.repos = append(l.repos, listed{key: c.key})+ }+ }+ slices.SortFunc(l.repos, func(a, b listed) int { return strings.Compare(a.key, b.key) })+ }+ if len(l.repos) == 0 && probed && len(l.failed) == 0 {+ l.failed = append(l.failed, "no source lists "+key)+ }+ logInfo("listed %d repos at %s by %s", len(l.repos), key, l.source)+ w.mu.Lock()+ w.lists[key] = l+ w.mu.Unlock()+ w.end(j, nil)+}++// listSource is one place that may list a path's repos.+type listSource struct {+ name, url string+ parse func(body []byte, next *string) ([]listed, error)+}++var (+ cgitRepo = regexp.MustCompile(`<td class='(?:top|sub)level-repo'><a (?:title='[^']*' )?href='([^']+)'`)+ stagitRepo = regexp.MustCompile(`<td><a href="([^"/]+)/log\.html">`)+)++// listSources are the sources for the path segs, in the order to ask them.+func listSources(segs []string) []listSource {+ host, rest := segs[0], strings.Join(segs[1:], "/")+ var out []listSource+ jsonList := func(field string) func([]byte, *string) ([]listed, error) {+ return func(b []byte, _ *string) ([]listed, error) {+ var v []map[string]any+ if err := json.Unmarshal(b, &v); err != nil {+ return nil, errors.New("not a list of repos")+ }+ var out []listed+ for _, m := range v {+ name, _ := m[field].(string)+ desc, _ := m["description"].(string)+ if name != "" {+ out = append(out, listed{key: host + "/" + name, desc: desc})+ }+ }+ return out, nil+ }+ }+ switch {+ case host == "github.com" && len(segs) == 2:+ out = append(out, listSource{"the GitHub API", "https://api.github.com/users/" + rest + "/repos?per_page=100", jsonList("full_name")})+ case host == "gitlab.com" && len(segs) >= 2:+ if len(segs) == 2 {+ out = append(out, listSource{"the GitLab API", "https://gitlab.com/api/v4/users/" + rest + "/projects?per_page=100", jsonList("path_with_namespace")})+ }+ out = append(out, listSource{"the GitLab API", "https://gitlab.com/api/v4/groups/" + url.PathEscape(rest) + "/projects?per_page=100&include_subgroups=true", jsonList("path_with_namespace")})+ case host == "bitbucket.org" && len(segs) == 2:+ out = append(out, listSource{"the Bitbucket API", "https://api.bitbucket.org/2.0/repositories/" + rest + "?pagelen=100",+ func(b []byte, next *string) ([]listed, error) {+ var v struct {+ Values []struct{ Full_name, Description string }+ Next string+ }+ if err := json.Unmarshal(b, &v); err != nil {+ return nil, errors.New("not a list of repos")+ }+ *next = v.Next+ var out []listed+ for _, r := range v.Values {+ out = append(out, listed{key: host + "/" + r.Full_name, desc: r.Description})+ }+ return out, nil+ }})+ case len(segs) == 2 && !slices.Contains([]string{"github.com", "gitlab.com", "bitbucket.org"}, host):+ out = append(out, listSource{"the Forgejo API", webScheme + "://" + host + "/api/v1/users/" + rest + "/repos?limit=50", jsonList("full_name")})+ }+ if _, forge := depths[host]; forge || host == "gitlab.com" {+ return out+ }+ base := webScheme + "://" + strings.Join(segs, "/") + "/"+ out = append(out, listSource{"gitweb's project index", base + "?a=project_index", func(b []byte, _ *string) ([]listed, error) {+ var out []listed+ sc := bufio.NewScanner(strings.NewReader(string(b)))+ for sc.Scan() {+ f := strings.Fields(sc.Text())+ if len(f) == 0 || strings.ContainsAny(f[0], "<>\"") {+ return nil, errors.New("not a project index")+ }+ name, err := url.QueryUnescape(f[0])+ if err != nil {+ return nil, errors.New("not a project index")+ }+ out = append(out, listed{key: strings.Join(segs, "/") + "/" + strings.TrimSuffix(name, ".git")})+ }+ return out, nil+ }})+ out = append(out, listSource{"the frontend's front page", base, func(b []byte, _ *string) ([]listed, error) {+ var out []listed+ for _, m := range cgitRepo.FindAllSubmatch(b, -1) {+ p := strings.Trim(string(m[1]), "/")+ out = append(out, listed{key: host + "/" + strings.TrimSuffix(p, ".git")})+ }+ for _, m := range stagitRepo.FindAllSubmatch(b, -1) {+ out = append(out, listed{key: strings.Join(segs, "/") + "/" + string(m[1])})+ }+ return out, nil+ }})+ return out+}++var webClient = &http.Client{Timeout: 20 * time.Second}++// ask reads src, following the forge's pages up to 10.+func (w *web) ask(src listSource) ([]listed, error) {+ var out []listed+ u := src.url+ for page := 1; page <= 10 && u != ""; page++ {+ req, err := http.NewRequestWithContext(w.ctx, "GET", u, nil)+ if err != nil {+ return nil, err+ }+ req.Header.Set("User-Agent", progName)+ client := webClient+ if w.public {+ client = publicClient+ }+ resp, err := client.Do(req)+ if err != nil {+ return nil, err+ }+ b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))+ resp.Body.Close()+ if err != nil {+ return nil, err+ }+ if resp.StatusCode != http.StatusOK {+ return nil, errors.New(resp.Status)+ }+ next := ""+ got, err := src.parse(b, &next)+ if err != nil {+ return nil, err+ }+ out = append(out, got...)+ switch {+ case next != "":+ u = next+ case strings.Contains(src.url, "api") && len(got) >= 50 && !strings.Contains(src.url, "bitbucket"):+ u = src.url + "&page=" + strconv.Itoa(page+1)+ default:+ u = ""+ }+ }+ return out, nil+}