package main import ( "bytes" "encoding/base64" "flag" "fmt" "html" "log" "net/http" "net/url" "regexp" "sort" "strings" "github.com/BurntSushi/toml" ) type Config struct { BindTo string Gitea struct { URL, Org string } Template struct { AppName string HomepageHeaderHTML string CustomLogoPngBase64 string } } type Application struct { cfg Config rxRepoPage, rxRepoImage *regexp.Regexp } func (this *Application) Templatepage(w http.ResponseWriter, r *http.Request, pageDesc, extraHead string, cb func()) { pageTitle := this.cfg.Template.AppName if pageDesc != "" { pageTitle = pageDesc + ` | ` + pageTitle } w.Header().Set(`Content-Type`, `text/html; charset=UTF-8`) w.WriteHeader(200) fmt.Fprint(w, ` `+html.EscapeString(pageTitle)+` `+extraHead+`

`+html.EscapeString(this.cfg.Template.AppName)+`

`) cb() fmt.Fprint(w, ` `) } func (this *Application) internalError(w http.ResponseWriter, r *http.Request, err error) { log.Printf("%s %s: %s", r.Method, r.URL.Path, err) http.Error(w, "An internal error occurred.", 500) } func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) { repos, err := this.repos() if err != nil { this.internalError(w, r, fmt.Errorf("listing repos: %w", err)) return } topics := make(map[string][]string) for _, repo := range repos { if t, err := this.topicsForRepo(repo.Name); err == nil { topics[repo.Name] = t } } // Sort repos once alphabetically, to get alphabetical indexes... sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name }) alphabeticalOrderIndexes := make(map[string]int, len(repos)) for idx, repo := range repos { alphabeticalOrderIndexes[repo.Name] = idx } // But then make sure the final sort is by most-recently-created sort.Slice(repos, func(i, j int) bool { return repos[i].CreatedAt.After(repos[j].CreatedAt) }) // Ready for template this.Templatepage(w, r, "", "", func() { fmt.Fprint(w, ` `+this.cfg.Template.HomepageHeaderHTML+`

Projects

`) for _, repo := range repos { pageHref := html.EscapeString(`/` + url.PathEscape(repo.Name)) normalisedDesc := repo.Description normalisedDesc = strings.TrimRight(repo.Description, `.`) if len(normalisedDesc) > 0 { // Lowercase the first letter of the description, unless it starts with an acronym (all letters uppercase first word) or CamelCase word firstWord := strings.SplitN(normalisedDesc, " ", 2)[0] isAcronymOrCamelCase := len(firstWord) > 1 && (firstWord[1:] != strings.ToLower(firstWord[1:])) if !(isAcronymOrCamelCase || firstWord == `Go`) { normalisedDesc = strings.ToLower(normalisedDesc[0:1]) + normalisedDesc[1:] } // Add leading `` to separate from the repo title normalisedDesc = `, ` + normalisedDesc } rowClass := "" for _, topic := range topics[repo.Name] { rowClass += `taggedWith-` + topic + ` ` } fmt.Fprint(w, ` `) } fmt.Fprint(w, `
`+html.EscapeString(repo.Name)+``+html.EscapeString(normalisedDesc)+` more...
`) for _, topic := range topics[repo.Name] { fmt.Fprint(w, ``+html.EscapeString(topic)+` `) } fmt.Fprint(w, `
`) }) } func (this *Application) Bannerpage(w http.ResponseWriter, r *http.Request, repoName string) { images, err := this.imageFilesForRepo(repoName) if err != nil { this.internalError(w, r, fmt.Errorf("listing images: %w", err)) return } if len(images) == 0 { w.Header().Set(`Location`, `/static/no_image.png`) w.WriteHeader(301) return } w.Header().Set(`Location`, images[0].RawURL) w.WriteHeader(301) } func (this *Application) Repopage(w http.ResponseWriter, r *http.Request, repoName string) { repoURL := this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName) extraHead := "" readme, err := this.repoFile(repoName, `README.md`) if err != nil { this.internalError(w, r, fmt.Errorf("loading README.md: %w", err)) return } lines := strings.Split(string(readme), "\n") // We add some extra badges based on special text entries extraBadgesMd := ` ![](https://img.shields.io/badge/build-success-brightgreen)` extraBadgesMd += ` [![](https://img.shields.io/badge/vcs-git-green?logo=git)](` + repoURL + `)` // Inject more badges to 3rd line; or, create badges on 3rd line if there are none already if len(lines) >= 3 && strings.Contains(lines[2], `shields.io`) { lines[2] += ` ` + extraBadgesMd } else { // Push other lines down lines = append([]string{lines[0], lines[1], extraBadgesMd, ""}, lines[2:]...) } readmeHtml, err := this.renderMarkdown(repoName, strings.Join(lines, "\n")) if err != nil { this.internalError(w, r, fmt.Errorf("rendering markdown: %w", err)) return } images, err := this.imageFilesForRepo(repoName) if err != nil { this.internalError(w, r, fmt.Errorf("listing images: %w", err)) return } // If the Git repository contains a top-level go.mod file, allow vanity imports if goMod, err := this.repoFile(repoName, `go.mod`); err == nil { // Check the first line should be `module MODULENAME\n` firstLine := bytes.SplitN(goMod, []byte("\n"), 2)[0] if bytes.HasPrefix(firstLine, []byte("module ")) { moduleName := firstLine[7:] extraHead = `` } } // De-escalate all headers in rendered markdown to match our style repl := strings.NewReplacer(``, ``, ``, ``, ``, ``) // Ready for template this.Templatepage(w, r, repoName, extraHead, func() { projBodyclass := `projbody` if len(images) > 0 { projBodyclass += ` projbody_halfw` } fmt.Fprint(w, `
`) repl.WriteString(w, string(readmeHtml)) fmt.Fprint(w, `
`) if len(images) > 0 { fmt.Fprint(w, `
`) for _, img := range images { fmt.Fprint(w, ``) } fmt.Fprint(w, `
`) } fmt.Fprint(w, `
`) fmt.Fprint(w, `
`) // projbody }) } func (this *Application) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method == `GET` { if r.URL.Path == `/` { this.Homepage(w, r) } else if r.URL.Path == `/favicon.ico` { w.Header().Set(`Location`, `/static/logo.png`) w.WriteHeader(301) } else if r.URL.Path == `/robots.txt` { http.Error(w, "not found", 404) } else if parts := this.rxRepoImage.FindStringSubmatch(r.URL.Path); parts != nil { this.Bannerpage(w, r, parts[1]) } else if parts := this.rxRepoPage.FindStringSubmatch(r.URL.Path); parts != nil { // Support /repo.html URIs for backward compatibility if strings.HasSuffix(parts[1], `.html`) { w.Header().Set(`Location`, r.URL.Path[0:len(r.URL.Path)-5]) w.WriteHeader(301) return } // The regexp supports an optional trailing slash // Redirect to canonical no-trailing-slash if strings.HasSuffix(r.URL.Path, `/`) { w.Header().Set(`Location`, `/`+parts[1]) // n.b. parts[1] isn't urldecoded yet w.WriteHeader(301) return } // Proper decoding of special characters in repo path component repoName, err := url.PathUnescape(parts[1]) if err != nil { http.Error(w, "malformed url encoding in repository name", 400) return } this.Repopage(w, r, repoName) } else if r.URL.Path == `/static/logo.png` { if this.cfg.Template.CustomLogoPngBase64 != "" { logoPng, err := base64.StdEncoding.DecodeString(this.cfg.Template.CustomLogoPngBase64) if err != nil { this.internalError(w, r, fmt.Errorf("parsing base64 logo: %w", err)) return } w.Header().Set(`Content-Length`, fmt.Sprintf("%d", len(logoPng))) w.Header().Set(`Content-Type`, `image/png`) w.WriteHeader(200) w.Write(logoPng) } else { r.URL.Path = r.URL.Path[8:] http.FileServer(http.Dir(`static`)).ServeHTTP(w, r) } } else if strings.HasPrefix(r.URL.Path, `/static/`) { r.URL.Path = r.URL.Path[8:] http.FileServer(http.Dir(`static`)).ServeHTTP(w, r) } else { http.Error(w, "not found", 404) } } else { http.Error(w, "invalid method", 400) } } func main() { app := Application{ rxRepoPage: regexp.MustCompile(`^/([^/]+)/?$`), rxRepoImage: regexp.MustCompile(`^/:banner/([^/]+)/?$`), } configFile := flag.String(`ConfigFile`, `config.toml`, `Configuration file in TOML format`) flag.Parse() _, err := toml.DecodeFile(*configFile, &app.cfg) if err != nil { panic(err) } // Assert Gitea URL always has trailing slash if !strings.HasSuffix(app.cfg.Gitea.URL, `/`) { app.cfg.Gitea.URL += `/` } log.Fatal(http.ListenAndServe(app.cfg.BindTo, &app)) }