13 Commits

8 changed files with 184 additions and 37 deletions

View File

@@ -28,3 +28,21 @@ By default, Dokku will proxy HTTP on port 5000.
dokku apps:create teafolio dokku apps:create teafolio
dokku storage:mount teafolio /srv/teafolio-dokku/config.toml:/app/config.toml dokku storage:mount teafolio /srv/teafolio-dokku/config.toml:/app/config.toml
``` ```
## CHANGELOG
2020-11-08 v1.1.1
- Fix an issue with newer versions of Gitea that paginate repoistory list responses
- Fix an issue with blocking semaphores for a cancelled network request
2020-05-24 v1.1.0
- Support limiting the number of concurrent API requests to Gitea
- Display total number of projects
- Fix cosmetic issues with page background image, page height, and margins around thumbnails
2020-05-24 v1.0.1
- Remove image dependency from static files
- Fix a cosmetic issue with `h2`/`h3` margins
2020-05-05 v1.0.0
- Initial release

134
api.go
View File

@@ -2,9 +2,11 @@ package main
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -44,9 +46,20 @@ type MarkdownRequest struct {
Wiki bool Wiki bool
} }
// repos gets a list of Git repositories in this organisation. // reposPage gets a single page of the list of Git repositories in this organisation.
func (this *Application) repos() ([]Repo, error) { func (this *Application) reposPage(ctx context.Context, page, limit int) ([]Repo, error) {
resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/orgs/` + url.PathEscape(this.cfg.Gitea.Org) + `/repos`) err := this.apiSem.Acquire(ctx, 1)
if err != nil {
return nil, err // e.g. ctx closed
}
defer this.apiSem.Release(1)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, this.cfg.Gitea.URL+`api/v1/orgs/`+url.PathEscape(this.cfg.Gitea.Org)+fmt.Sprintf(`/repos?page=%d&limit=%d`, page, limit), nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -65,10 +78,47 @@ func (this *Application) repos() ([]Repo, error) {
return repos, nil return repos, nil
} }
// repos gets a list of Git repositories in this organisation. It may have to
// make multiple network requests.
func (this *Application) repos(ctx context.Context) ([]Repo, error) {
// Seems like gitea-1.13.0-rc1 returns 30 results by default, and supports up to a limit of 100
// Make a much larger request
ret := make([]Repo, 0)
nextPage := 1 // Counting starts at 1
for {
page, err := this.reposPage(ctx, nextPage, 300)
if err != nil {
return nil, err
}
log.Printf("Page %d with %d results", nextPage, len(page))
if len(page) == 0 && len(ret) > 0 {
return ret, nil // Found enough already
}
ret = append(ret, page...)
nextPage += 1
}
}
// repoFile gets a single file from the default branch of the git repository // repoFile gets a single file from the default branch of the git repository
// Usually the default branch is `master`. // Usually the default branch is `master`.
func (this *Application) repoFile(repo, filename string) ([]byte, error) { func (this *Application) repoFile(ctx context.Context, repo, filename string) ([]byte, error) {
resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/repos/` + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repo) + `/contents/` + url.PathEscape(filename)) err := this.apiSem.Acquire(ctx, 1)
if err != nil {
return nil, err // e.g. ctx closed
}
defer this.apiSem.Release(1)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, this.cfg.Gitea.URL+`api/v1/repos/`+url.PathEscape(this.cfg.Gitea.Org)+`/`+url.PathEscape(repo)+`/contents/`+url.PathEscape(filename), nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -87,8 +137,19 @@ func (this *Application) repoFile(repo, filename string) ([]byte, error) {
return cr.Content, nil return cr.Content, nil
} }
func (this *Application) filesInDirectory(repo, dir string) ([]ReaddirEntry, error) { func (this *Application) filesInDirectory(ctx context.Context, repo, dir string) ([]ReaddirEntry, error) {
resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/repos/` + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repo) + `/contents/` + dir) // n.b. $dir param not escaped err := this.apiSem.Acquire(ctx, 1)
if err != nil {
return nil, err // e.g. ctx closed
}
defer this.apiSem.Release(1)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, this.cfg.Gitea.URL+`api/v1/repos/`+url.PathEscape(this.cfg.Gitea.Org)+`/`+url.PathEscape(repo)+`/contents/`+dir, nil) // n.b. $dir param not escaped
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -121,13 +182,13 @@ func (this *Application) filesInDirectory(repo, dir string) ([]ReaddirEntry, err
// imageFilesForRepo finds documentation images for the repository. // imageFilesForRepo finds documentation images for the repository.
// It searches the dist/ and doc/ subdirectories. // It searches the dist/ and doc/ subdirectories.
func (this *Application) imageFilesForRepo(repo string) ([]ReaddirEntry, error) { func (this *Application) imageFilesForRepo(ctx context.Context, repo string) ([]ReaddirEntry, error) {
ret := []ReaddirEntry{} ret := []ReaddirEntry{}
for _, dirName := range []string{`dist`, `doc`} { for _, dirName := range []string{`dist`, `doc`} {
files, err := this.filesInDirectory(repo, dirName) files, err := this.filesInDirectory(ctx, repo, dirName)
if err != nil { if err != nil {
return nil, fmt.Errorf("readdir(%s): %w", dirName, err) return nil, fmt.Errorf("readdir(%s): %w", dirName, err)
} }
@@ -142,8 +203,19 @@ func (this *Application) imageFilesForRepo(repo string) ([]ReaddirEntry, error)
return ret, nil return ret, nil
} }
func (this *Application) topicsForRepo(repo string) ([]string, error) { func (this *Application) topicsForRepo(ctx context.Context, repo string) ([]string, error) {
resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/repos/` + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repo) + `/topics`) err := this.apiSem.Acquire(ctx, 1)
if err != nil {
return nil, err // e.g. ctx closed
}
defer this.apiSem.Release(1)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, this.cfg.Gitea.URL+`api/v1/repos/`+url.PathEscape(this.cfg.Gitea.Org)+`/`+url.PathEscape(repo)+`/topics`, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -163,19 +235,30 @@ func (this *Application) topicsForRepo(repo string) ([]string, error) {
} }
// renderMarkdown calls the remote Gitea server's own markdown renderer. // renderMarkdown calls the remote Gitea server's own markdown renderer.
func (this *Application) renderMarkdown(repoName string, body string) ([]byte, error) { func (this *Application) renderMarkdown(ctx context.Context, repoName string, body string) ([]byte, error) {
req := MarkdownRequest{ err := this.apiSem.Acquire(ctx, 1)
if err != nil {
return nil, err // e.g. ctx closed
}
defer this.apiSem.Release(1)
jb, err := json.Marshal(MarkdownRequest{
Context: this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName) + `/src/branch/master`, Context: this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName) + `/src/branch/master`,
Mode: "gfm", // magic constant - Github Flavoured Markdown Mode: "gfm", // magic constant - Github Flavoured Markdown
Text: body, Text: body,
} })
jb, err := json.Marshal(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := http.Post(this.cfg.Gitea.URL+`api/v1/markdown/`, `application/json`, bytes.NewReader(jb)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, this.cfg.Gitea.URL+`api/v1/markdown/`, bytes.NewReader(jb))
if err != nil {
return nil, err
}
req.Header.Set(`Content-Type`, `application/json`)
req.Header.Set(`Content-Length`, fmt.Sprintf("%d", len(jb)))
resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -189,8 +272,21 @@ func (this *Application) renderMarkdown(repoName string, body string) ([]byte, e
} }
// renderMarkdownRaw calls the remote Gitea server's own markdown renderer. // renderMarkdownRaw calls the remote Gitea server's own markdown renderer.
func (this *Application) renderMarkdownRaw(body []byte) ([]byte, error) { func (this *Application) renderMarkdownRaw(ctx context.Context, body []byte) ([]byte, error) {
resp, err := http.Post(this.cfg.Gitea.URL+`api/v1/markdown/raw`, `text/plain`, bytes.NewReader(body)) err := this.apiSem.Acquire(ctx, 1)
if err != nil {
return nil, err // e.g. ctx closed
}
defer this.apiSem.Release(1)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, this.cfg.Gitea.URL+`api/v1/markdown/raw`, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set(`Content-Type`, `text/plain`)
req.Header.Set(`Content-Length`, fmt.Sprintf("%d", len(body)))
resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -5,6 +5,7 @@ BindTo="0.0.0.0:5656"
[Gitea] [Gitea]
URL="https://gitea.com/" URL="https://gitea.com/"
Org="gitea" Org="gitea"
MaxConnections=2 # Use zero for unlimited
[Redirect] [Redirect]
"old-project-name" = "new-project-name" "old-project-name" = "new-project-name"

5
go.mod
View File

@@ -2,4 +2,7 @@ module teafolio
go 1.13 go 1.13
require github.com/BurntSushi/toml v0.3.1 require (
github.com/BurntSushi/toml v0.3.1
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a
)

2
go.sum
View File

@@ -1,2 +1,4 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

33
main.go
View File

@@ -14,12 +14,14 @@ import (
"strings" "strings"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"golang.org/x/sync/semaphore"
) )
type Config struct { type Config struct {
BindTo string BindTo string
Gitea struct { Gitea struct {
URL, Org string URL, Org string
MaxConnections int64
} }
Redirect map[string]string Redirect map[string]string
Template struct { Template struct {
@@ -33,6 +35,7 @@ type Application struct {
cfg Config cfg Config
rxRepoPage, rxRepoImage *regexp.Regexp rxRepoPage, rxRepoImage *regexp.Regexp
apiSem *semaphore.Weighted
} }
func (this *Application) Templatepage(w http.ResponseWriter, r *http.Request, pageDesc, extraHead string, cb func()) { func (this *Application) Templatepage(w http.ResponseWriter, r *http.Request, pageDesc, extraHead string, cb func()) {
@@ -77,7 +80,9 @@ func (this *Application) internalError(w http.ResponseWriter, r *http.Request, e
} }
func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) { func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) {
repos, err := this.repos() ctx := r.Context()
repos, err := this.repos(ctx)
if err != nil { if err != nil {
this.internalError(w, r, fmt.Errorf("listing repos: %w", err)) this.internalError(w, r, fmt.Errorf("listing repos: %w", err))
return return
@@ -85,7 +90,7 @@ func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) {
topics := make(map[string][]string) topics := make(map[string][]string)
for _, repo := range repos { for _, repo := range repos {
if t, err := this.topicsForRepo(repo.Name); err == nil { if t, err := this.topicsForRepo(ctx, repo.Name); err == nil {
topics[repo.Name] = t topics[repo.Name] = t
} }
} }
@@ -116,7 +121,7 @@ func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) {
<option value="data-sort-mt">Recent updates</option> <option value="data-sort-mt">Recent updates</option>
</select> </select>
<h2>Projects</h2> <h2>Projects <small>(`+fmt.Sprintf("%d", len(repos))+`)</small></h2>
<table id="projtable-main" class="projtable"> <table id="projtable-main" class="projtable">
`) `)
for _, repo := range repos { for _, repo := range repos {
@@ -175,8 +180,9 @@ func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) {
} }
func (this *Application) Bannerpage(w http.ResponseWriter, r *http.Request, repoName string) { func (this *Application) Bannerpage(w http.ResponseWriter, r *http.Request, repoName string) {
ctx := r.Context()
images, err := this.imageFilesForRepo(repoName) images, err := this.imageFilesForRepo(ctx, repoName)
if err != nil { if err != nil {
this.internalError(w, r, fmt.Errorf("listing images: %w", err)) this.internalError(w, r, fmt.Errorf("listing images: %w", err))
return return
@@ -193,11 +199,11 @@ func (this *Application) Bannerpage(w http.ResponseWriter, r *http.Request, repo
} }
func (this *Application) Repopage(w http.ResponseWriter, r *http.Request, repoName string) { 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) repoURL := this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName)
extraHead := "" extraHead := ""
readme, err := this.repoFile(repoName, `README.md`) readme, err := this.repoFile(ctx, repoName, `README.md`)
if err != nil { if err != nil {
this.internalError(w, r, fmt.Errorf("loading README.md: %w", err)) this.internalError(w, r, fmt.Errorf("loading README.md: %w", err))
return return
@@ -217,20 +223,20 @@ func (this *Application) Repopage(w http.ResponseWriter, r *http.Request, repoNa
lines = append([]string{lines[0], lines[1], extraBadgesMd, ""}, lines[2:]...) lines = append([]string{lines[0], lines[1], extraBadgesMd, ""}, lines[2:]...)
} }
readmeHtml, err := this.renderMarkdown(repoName, strings.Join(lines, "\n")) readmeHtml, err := this.renderMarkdown(ctx, repoName, strings.Join(lines, "\n"))
if err != nil { if err != nil {
this.internalError(w, r, fmt.Errorf("rendering markdown: %w", err)) this.internalError(w, r, fmt.Errorf("rendering markdown: %w", err))
return return
} }
images, err := this.imageFilesForRepo(repoName) images, err := this.imageFilesForRepo(ctx, repoName)
if err != nil { if err != nil {
this.internalError(w, r, fmt.Errorf("listing images: %w", err)) this.internalError(w, r, fmt.Errorf("listing images: %w", err))
return return
} }
// If the Git repository contains a top-level go.mod file, allow vanity imports // If the Git repository contains a top-level go.mod file, allow vanity imports
if goMod, err := this.repoFile(repoName, `go.mod`); err == nil { if goMod, err := this.repoFile(ctx, repoName, `go.mod`); err == nil {
// Check the first line should be `module MODULENAME\n` // Check the first line should be `module MODULENAME\n`
firstLine := bytes.SplitN(goMod, []byte("\n"), 2)[0] firstLine := bytes.SplitN(goMod, []byte("\n"), 2)[0]
@@ -384,5 +390,12 @@ func main() {
app.cfg.Gitea.URL += `/` app.cfg.Gitea.URL += `/`
} }
// Create semaphore
if app.cfg.Gitea.MaxConnections == 0 { // unlimited
app.apiSem = semaphore.NewWeighted(99999)
} else {
app.apiSem = semaphore.NewWeighted(app.cfg.Gitea.MaxConnections)
}
log.Fatal(http.ListenAndServe(app.cfg.BindTo, &app)) log.Fatal(http.ListenAndServe(app.cfg.BindTo, &app))
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -25,7 +25,7 @@ h1 a {
h1 a:hover { h1 a:hover {
color:black; color:black;
} }
h1,h2,h3 { h1 {
margin-top:0; margin-top:0;
} }
.code { .code {
@@ -48,8 +48,7 @@ h1,h2,h3 {
html, body { html, body {
/* structural */ /* structural */
height:100%; min-height:100vh;
min-height:100%;
margin:0; margin:0;
border:0; border:0;
padding:0; padding:0;
@@ -58,18 +57,23 @@ html, body {
font-family:"Helvetica Neue","Segoe UI",Arial,sans-serif; font-family:"Helvetica Neue","Segoe UI",Arial,sans-serif;
font-size:13px; font-size:13px;
line-height:1.4; line-height:1.4;
background:#DDD url('greyzz.png'); /* thanks subtlepatterns.com ! */
color:#333; color:#333;
} }
/* Create background pattern by layering two gradients */
html {
background: repeating-linear-gradient(45deg, #FFF, #f0f0f0 3px, #fff 6px);
}
body {
background: repeating-linear-gradient(135deg, rgba(255,255,255,0), rgba(255,255,255,255) 3px, rgba(255,255,255,0) 6px);
}
#container { #container {
margin:0 auto; margin:0 auto;
width:960px; width:960px;
position:relative; position:relative;
height:auto !important; min-height:100vh;
height:100%; /* oldIE */
min-height:100%;
/* cosmetic */ /* cosmetic */
background:white; background:white;
@@ -137,6 +141,9 @@ html, body {
float:left; float:left;
width: 860px; /* 740px full - 60px rhs column - 2px border */ width: 860px; /* 740px full - 60px rhs column - 2px border */
} }
.projbody_halfw h2:first-child {
margin-top: 0;
}
.projbody_fullw { .projbody_fullw {
} }
@@ -147,6 +154,13 @@ html, body {
/* */ /* */
img[src*="shields.io"] {
width: auto;
height: 20px; /* Set default height to avoid reflow flashing */
}
/* */
@media screen and (max-width:960px) { @media screen and (max-width:960px) {
#container { #container {