diff --git a/main.go b/main.go index 9c9e828..255efe5 100644 --- a/main.go +++ b/main.go @@ -1,16 +1,10 @@ package main import ( - "bytes" - "encoding/base64" "flag" - "fmt" - "html" "log" "net/http" - "net/url" "regexp" - "sort" "strings" "github.com/BurntSushi/toml" @@ -38,339 +32,6 @@ type Application struct { apiSem *semaphore.Weighted } -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) { - ctx := r.Context() - - repos, err := this.repos(ctx) - 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(ctx, 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 (`+fmt.Sprintf("%d", len(repos))+`)

- -`) - 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) { - ctx := r.Context() - - images, err := this.imageFilesForRepo(ctx, 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) { - ctx := r.Context() - repoURL := this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName) - extraHead := "" - - readme, err := this.repoFile(ctx, 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(ctx, repoName, strings.Join(lines, "\n")) - if err != nil { - this.internalError(w, r, fmt.Errorf("rendering markdown: %w", err)) - return - } - - images, err := this.imageFilesForRepo(ctx, 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(ctx, 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 - } - - // Maybe it's a redirected project (alternative name) - if rename, ok := this.cfg.Redirect[repoName]; ok { - w.Header().Set(`Location`, `/`+url.PathEscape(rename)) - w.WriteHeader(301) - 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 if r.URL.Query().Get("go-get") == "1" { - // This wasn't one of our standard `/repo` paths, but there is the ?go-get=1 parameter - // It must be a subpackage request - // We can't serve the proper go-import meta tag immediately because - // we haven't looked up the go.mod yet. Just redirect to the root - // package - `go get` will follow redirects and the resulting meta tag is correct - - slashParts := strings.SplitN(r.URL.Path, `/`, 3) // len === 3 is guaranteed from earlier if cases - - w.Header().Set(`Location`, `/`+slashParts[1]) - w.WriteHeader(301) - return - - } else { - http.Error(w, "not found", 404) - } - - } else { - http.Error(w, "invalid method", 400) - } - -} - func main() { app := Application{ rxRepoPage: regexp.MustCompile(`^/([^/]+)/?$`), diff --git a/pages.go b/pages.go new file mode 100644 index 0000000..6df2931 --- /dev/null +++ b/pages.go @@ -0,0 +1,250 @@ +package main + +import ( + "bytes" + "fmt" + "html" + "log" + "net/http" + "net/url" + "sort" + "strings" +) + +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) { + ctx := r.Context() + + repos, err := this.repos(ctx) + 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(ctx, 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 (`+fmt.Sprintf("%d", len(repos))+`)

+ +`) + 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) { + ctx := r.Context() + + images, err := this.imageFilesForRepo(ctx, 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) { + ctx := r.Context() + repoURL := this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName) + extraHead := "" + + readme, err := this.repoFile(ctx, 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(ctx, repoName, strings.Join(lines, "\n")) + if err != nil { + this.internalError(w, r, fmt.Errorf("rendering markdown: %w", err)) + return + } + + images, err := this.imageFilesForRepo(ctx, 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(ctx, 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 + }) +} diff --git a/router.go b/router.go new file mode 100644 index 0000000..be8c5a3 --- /dev/null +++ b/router.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + "strings" +) + +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 + } + + // Maybe it's a redirected project (alternative name) + if rename, ok := this.cfg.Redirect[repoName]; ok { + w.Header().Set(`Location`, `/`+url.PathEscape(rename)) + w.WriteHeader(301) + 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 if r.URL.Query().Get("go-get") == "1" { + // This wasn't one of our standard `/repo` paths, but there is the ?go-get=1 parameter + // It must be a subpackage request + // We can't serve the proper go-import meta tag immediately because + // we haven't looked up the go.mod yet. Just redirect to the root + // package - `go get` will follow redirects and the resulting meta tag is correct + + slashParts := strings.SplitN(r.URL.Path, `/`, 3) // len === 3 is guaranteed from earlier if cases + + w.Header().Set(`Location`, `/`+slashParts[1]) + w.WriteHeader(301) + return + + } else { + http.Error(w, "not found", 404) + } + + } else { + http.Error(w, "invalid method", 400) + } + +}