From d7a964c186d980f83776a4fffbe61d543d946977 Mon Sep 17 00:00:00 2001 From: mappu Date: Sat, 2 May 2020 14:16:49 +1200 Subject: [PATCH] initial commit --- .gitignore | 5 + Dockerfile | 15 ++ README.md | 19 +++ api.go | 204 +++++++++++++++++++++++++++ config.toml.sample | 16 +++ go.mod | 5 + go.sum | 2 + main.go | 327 ++++++++++++++++++++++++++++++++++++++++++++ static/greyzz.png | Bin 0 -> 6084 bytes static/logo.png | Bin 0 -> 243 bytes static/no_image.png | Bin 0 -> 436 bytes static/site.js | 83 +++++++++++ static/style.css | 221 ++++++++++++++++++++++++++++++ 13 files changed, 897 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 api.go create mode 100644 config.toml.sample create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 static/greyzz.png create mode 100644 static/logo.png create mode 100644 static/no_image.png create mode 100644 static/site.js create mode 100644 static/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2751ada --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Local config +config.toml + +# Binary artefacts +teafolio diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0921f0f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# Dockerfile for production Teafolio deployments + +FROM golang:1.14-alpine AS builder + +WORKDIR /app +COPY . . +RUN go build -ldflags "-s -w" && chmod +x teafolio + +FROM alpine:latest + +WORKDIR /app +COPY --from=builder /app/teafolio /app/teafolio +COPY /static /app/static + +ENTRYPOINT [ "/app/teafolio" ] diff --git a/README.md b/README.md new file mode 100644 index 0000000..721f414 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# teafolio + +Teafolio is a web-based portfolio frontend for a Gitea server. + +Compared to the earlier [codesite](https://code.ivysaur.me/codesite/) project, the repository list and detailed information is loaded live from a Gitea server. + +Written in Go + +## Usage + +1. Compile the binary: `go build` +2. Modify the sample `config.toml` file to point to your Gitea instance + - `teafolio` will look for `config.toml` in the current directory, or, you can supply a custom path with `-ConfigFile` +3. Deploy binary + `static/` directory to webserver + +### Production (Docker) + +1. `docker build -t teafolio:latest .` +2. `docker run --restart=always -d -p 5656:5656 -v $(pwd)/config.toml:/app/config.toml teafolio:latest` diff --git a/api.go b/api.go new file mode 100644 index 0000000..e6e35cd --- /dev/null +++ b/api.go @@ -0,0 +1,204 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" +) + +type Repo struct { + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ContentsResponse struct { + Content []byte `json:"content"` // Assume base64 "encoding" parameter in Gitea response, and use Go's auto decode +} + +type TopicsResponse struct { + Topics []string `json:"topics"` +} + +type ReaddirEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Size int `json:"size"` + RawURL string `json:"download_url"` +} + +func (rde ReaddirEntry) isImage() bool { + return strings.HasSuffix(rde.Name, `.png`) || strings.HasSuffix(rde.Name, `.jpg`) || strings.HasSuffix(rde.Name, `.jpeg`) +} + +type MarkdownRequest struct { + Context string + Mode string + Text string + Wiki bool +} + +// repos gets a list of Git repositories in this organisation. +func (this *Application) repos() ([]Repo, error) { + resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/orgs/` + url.PathEscape(this.cfg.Gitea.Org) + `/repos`) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var repos []Repo + err = json.NewDecoder(resp.Body).Decode(&repos) + if err != nil { + return nil, err + } + + return repos, nil +} + +// repoFile gets a single file from the default branch of the git repository +// Usually the default branch is `master`. +func (this *Application) repoFile(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)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var cr ContentsResponse + err = json.NewDecoder(resp.Body).Decode(&cr) + if err != nil { + return nil, err + } + + return cr.Content, nil +} + +func (this *Application) filesInDirectory(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 + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + + // "No files found" happens with a HTTP 500/404 error depending on Gitea version. Catch this special case + if resp.StatusCode == 500 || resp.StatusCode == 404 { + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if strings.Contains(string(b), `does not exist`) { + return []ReaddirEntry{}, nil // no files found + } + } + + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var ret []ReaddirEntry + err = json.NewDecoder(resp.Body).Decode(&ret) + if err != nil { + return nil, err + } + + return ret, nil +} + +// imageFilesForRepo finds documentation images for the repository. +// It searches the dist/ and doc/ subdirectories. +func (this *Application) imageFilesForRepo(repo string) ([]ReaddirEntry, error) { + + ret := []ReaddirEntry{} + + for _, dirName := range []string{`dist`, `doc`} { + + files, err := this.filesInDirectory(repo, dirName) + if err != nil { + return nil, fmt.Errorf("readdir(%s): %w", dirName, err) + } + + for _, f := range files { + if f.isImage() { + ret = append(ret, f) + } + } + } + + return ret, nil +} + +func (this *Application) topicsForRepo(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`) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var tr TopicsResponse + err = json.NewDecoder(resp.Body).Decode(&tr) + if err != nil { + return nil, err + } + + return tr.Topics, nil +} + +// renderMarkdown calls the remote Gitea server's own markdown renderer. +func (this *Application) renderMarkdown(repoName string, body string) ([]byte, error) { + req := MarkdownRequest{ + Context: this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName) + `/src/branch/master`, + Mode: "gfm", // magic constant - Github Flavoured Markdown + Text: body, + } + + jb, err := json.Marshal(req) + if err != nil { + return nil, err + } + + resp, err := http.Post(this.cfg.Gitea.URL+`api/v1/markdown/`, `application/json`, bytes.NewReader(jb)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + return ioutil.ReadAll(resp.Body) +} + +// renderMarkdownRaw calls the remote Gitea server's own markdown renderer. +func (this *Application) renderMarkdownRaw(body []byte) ([]byte, error) { + resp, err := http.Post(this.cfg.Gitea.URL+`api/v1/markdown/raw`, `text/plain`, bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + return ioutil.ReadAll(resp.Body) +} diff --git a/config.toml.sample b/config.toml.sample new file mode 100644 index 0000000..d67150a --- /dev/null +++ b/config.toml.sample @@ -0,0 +1,16 @@ +# teafolio config file + +BindTo="0.0.0.0:5656" + +[Gitea] +URL="https://gitea.com/" +Org="gitea" + +[Template] +AppName = "Teafolio" + +HomepageHeaderHTML=""" +

+ Teafolio is a web-based portfolio frontend for a Gitea server. +

+""" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..21f55e5 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module teafolio + +go 1.13 + +require github.com/BurntSushi/toml v0.3.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9cb2df8 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3c5437d --- /dev/null +++ b/main.go @@ -0,0 +1,327 @@ +package main + +import ( + "bytes" + "encoding/base64" + "flag" + "fmt" + "html" + "log" + "net/http" + "net/url" + "regexp" + "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 + } + } + + // 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 + `)` + + // Convert [`Written in LANG` "\n"] to badge + // This was special syntax used by codesite + writtenInPrefix := `Written in ` + for i, line := range lines { + if strings.HasPrefix(line, writtenInPrefix) { + extraBadgesMd += ` ![](https://img.shields.io/badge/written%20in-` + url.QueryEscape(line[len(writtenInPrefix):]) + `-blue)` + lines = append(lines[0:i], lines[i+1:]...) + break + } + } + + // 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 { + this.Repopage(w, r, parts[1]) + + } 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)) +} diff --git a/static/greyzz.png b/static/greyzz.png new file mode 100644 index 0000000000000000000000000000000000000000..9c919229680bcadfb1e46e7262758903bb908523 GIT binary patch literal 6084 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 KMNUMnLSTZNc&<7C literal 0 HcmV?d00001 diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..3cddad4f67357197a1da0d0f66f87a09f0539577 GIT binary patch literal 243 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjEX7WqAsj$Z!;#Vfz_&W&8{most>UfiQ=$^_- zwwR!mtZ8=F?-{BxU*VCtT()q{?PJN$SaToGXLkDZmEkx`JU_z*M(3ONm>8;Gvq(&i zV)k8G>TxPz&aSztoBBIHg%?UI#I@T@Gd^8^;DyraSClFjnjH+g!*YVVU$`bj o=U>0xq(-UbdY1Fp-&P;svicEfGu1Jd2k2@BPgg&ebxsLQ02@GB;Q#;t literal 0 HcmV?d00001 diff --git a/static/no_image.png b/static/no_image.png new file mode 100644 index 0000000000000000000000000000000000000000..504b7e0fa355a1c6582db142af9d0a493c6f6bfd GIT binary patch literal 436 zcmV;l0ZaagP)s|L^bb*4Ebd_xI)H<;={?-rn9ybFdo#000SaNLh0L01FZT z01FZU(%pXi0000ObVXQnQ*UN;cVTj606}DLVr3vkX>w(EZ*psMPqQCR0003GNkl06CL#$5U8sY(VJ6?_g76qQ%GDXtf;>iTl!?5 z4C0>PUtcHb4o#E}*3{rZn$C$qTGqC?+Nb5VBU|j#vyys=u9n+_5UXdR?4(yGy{FjH z#(t2tExyt-el6wHH1(WE+{CRV3w3oUj2hrCVV(PK|pvJy?|Im}=KwIl! eL$s4TzWxR?GKt$03Fw3X0000 margins */ + margin-top: 1em; + margin-bottom: 1em; +} + +/* */ + +html, body { + /* structural */ + height:100%; + min-height:100%; + margin:0; + border:0; + padding:0; + + /* cosmetic */ + font-family:"Helvetica Neue","Segoe UI",Arial,sans-serif; + font-size:13px; + line-height:1.4; + background:#DDD url('greyzz.png'); /* thanks subtlepatterns.com ! */ + color:#333; +} + +#container { + margin:0 auto; + width:960px; + position:relative; + + height:auto !important; + height:100%; /* oldIE */ + min-height:100%; + + /* cosmetic */ + background:white; +} + +#content { + padding:14px; + background:white; +} + +/* */ + +.tag::before { + content:""; + + display:inline-block; + width:7px; + height:7px; + + margin-right:2px; + + background:transparent url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAZUlEQVQI12P4//8/Awyrqqp6KSkp/QdhFRWV7cgS+kDBN0B8RVlZuRikACahB+T8ger6BMTXwJJAiTwg4xvMOCj+ART3ZwCa3YYm8QcopgsyEWasDVT3W5AVMHcgO6gViNORXQ8A84NToxbSsJcAAAAASUVORK5CYII=') no-repeat 0 0; +} + +.tag-filter-warn { + position:fixed; + top:0; + right:0; + + padding:4px; + + background:lightyellow; + border-bottom: 1px solid #888; + border-left:1px solid #888; +} + +/* */ + +.projtable { + border-collapse: collapse; + width:100%; +} +.projtable tr { + transition:0.2s linear; +} +.projtable tr:hover { + background:#F8F8F8; +} +.projtable td { + padding: 2px 4px; +} +.projtable small { + color:grey; + font-style:italic; +} +.projtable tr td:first-child { + width:95px; +} + +.projinfo { +} +.projbody { +} +.projbody_halfw { + float:left; + width: 860px; /* 740px full - 60px rhs column - 2px border */ +} +.projbody_fullw { + +} +.projimg { + float:right; + width:62px; /* 60px + 2px border */ +} + +/* */ + +@media screen and (max-width:960px) { + + #container { + width:100%; + } + .projimg { + float:clear; + width:100%; + } + .projbody_halfw { + float:clear; + width:100%; + } + +} + +/* */ + +#logo { + background:transparent url('logo.png') no-repeat 0 0; + width:24px; + height:24px; + display:inline-block; + margin-right:4px; + position:relative; + top:4px; +} + +/* */ + +.homeimage { + width:90px; + height:32px; + + object-fit: cover; +} + +.thumbimage { + width:60px; + height:60px; + opacity: 0.8; + transition:0.2s opacity; + border:1px solid lightgrey; + + object-fit: cover; +} +.thumbimage:hover { + opacity:1.0; +} + +.no-image { + width:90px; + height:32px; + display:block; + background: white url('no_image.png') no-repeat 0 0; +} + +/* */ + +.downloads-small { + margin:0; + padding:0; + list-style-type:none; +} + +.downloads-small li:before { + content:"•"; +} +.downloads-small li a:before { + font-weight:bold; + content:"⇩ "; +}