From 558b7d87fe084505266ad0bea6c529056ae63ee3 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Sat, 17 Oct 2026 02:51:46 -0700 Subject: [PATCH] Projects plugin: GitHub repos plus hand-entered projects on a /projects page Lists a GitHub user's or organization's public repos (forks and archived skipped, most-starred first) cached in the plugin store and refreshed by an hourly job, with an extra_projects textarea for entries GitHub doesn't have. Closes goblogplatform/GoBlog#648 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 39 ++++++ .gitignore | 2 + CHANGELOG.md | 5 + README.md | 47 ++++++- fetch.go | 65 +++++++++ fetch_test.go | 79 +++++++++++ go.mod | 7 + go.sum | 2 + goblog-plugin.json | 12 ++ main.go | 239 ++++++++++++++++++++++++++++++++++ main_native.go | 5 + project.go | 197 ++++++++++++++++++++++++++++ project_test.go | 94 +++++++++++++ 13 files changed, 792 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 fetch.go create mode 100644 fetch_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 goblog-plugin.json create mode 100644 main.go create mode 100644 main_native.go create mode 100644 project.go create mode 100644 project_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2ec993c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release +on: + push: + branches: [main] + pull_request: + release: + types: [published] +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Test (native) + run: go test ./... + - name: Build plugin.wasm + run: GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm . + build: + if: github.event_name == 'release' + needs: test + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Build plugin.wasm + run: GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm . + - name: Upload to the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$TAG" plugin.wasm --clobber diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c5b44c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +plugin.wasm +*.test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4c6e709 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +- First release: a Projects page (`/projects`) listing a GitHub user's or organization's public repositories (forks and archived repos skipped, most-starred first) plus hand-entered projects from the `extra_projects` setting. Repos are cached in the plugin store and refreshed by an hourly job once older than `cache_hours`. diff --git a/README.md b/README.md index 83aff83..5db1139 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,47 @@ # goblog-plugin-projects -goblog plugin: a Projects page listing your GitHub repositories plus hand-entered projects (WebAssembly) + +A [goblog](https://github.com/goblogplatform/goblog) WebAssembly plugin that adds a **Projects** page (`/projects`, in the nav) listing your public GitHub repositories (name linked to the repo, description, language and stars, most-starred first) plus any projects you enter by hand. + +## Install + +From your goblog's **Admin → Plugins → Browse**, search for *Projects* and click **Install** (goblog 0.3.0 or newer). Or download `plugin.wasm` from the [latest release](https://github.com/goblogplatform/goblog-plugin-projects/releases/latest) into `plugins/wasm/projects.wasm`, write `plugins/wasm/projects.json` containing `{"allowed_hosts":["api.github.com"]}`, and restart goblog. + +## Settings + +Under **Admin → Settings → Projects**: + +| Setting | Default | Meaning | +|---|---|---| +| `enabled` | `false` | Set to `true` to enable the Projects page | +| `github_user` | | GitHub user or organization whose public repositories are listed; forks and archived repos are skipped. Leave empty to show only `extra_projects` | +| `github_token` | | Optional; a token (no scopes needed) raises the API rate limit from 60 to 5,000 requests an hour | +| `exclude_repos` | | Comma-separated repository names to hide (case-insensitive) | +| `repo_limit` | `30` | Maximum number of repositories to show (the most-starred N; at most 500) | +| `extra_projects` | | Projects shown above the repos, one per line: `Name \| URL \| Description`. URL and description are optional; blank lines and lines starting with `#` are skipped | +| `cache_hours` | `24` | How long fetched repos are reused before the hourly job refreshes them (`0` or anything invalid means the default) | + +For example: + +``` +goblog.live | https://www.goblog.live | The site this plugin was built for +Thesis | https://example.com/thesis.pdf +``` + +## How it works + +The plugin talks to `api.github.com` only (`/users/{user}/repos`, which also works for organizations), and goblog's sandbox blocks every other host. + +Repos are served from the cache: fetched repos are kept in the plugin store (goblog's `plugin_store` table) and the page always shows what is there, so visitors never wait on the API. An hourly `refresh` job re-fetches once the cache is older than `cache_hours`. If GitHub is unreachable the last cached list stays up and the job simply tries again next hour. The page only fetches for itself when the store is empty (first view after install, or after changing `github_user`); if that fetch fails the page shows the extra projects with a note that GitHub projects are temporarily unavailable, and does not retry for ten minutes. Changes to `exclude_repos` and `repo_limit` apply when the cache is next refreshed (within `cache_hours`). `extra_projects` needs no network and changes show up immediately. + +## Build it yourself + +```bash +go test ./... # unit tests run natively +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm . +``` + +Needs Go 1.25 or newer (`go.mod` pins the toolchain, so `GOTOOLCHAIN=auto` fetches it). Check the result with `goblog validate-plugin plugin.wasm`. Tag a release as `vX.Y.Z`; the workflow builds and uploads `plugin.wasm` for you. + +## License + +Apache-2.0. diff --git a/fetch.go b/fetch.go new file mode 100644 index 0000000..76d0b48 --- /dev/null +++ b/fetch.go @@ -0,0 +1,65 @@ +package main + +import ( + "errors" + "fmt" + "net/url" +) + +const ( + apiBase = "https://api.github.com" + pageSize = 100 +) + +// getter performs an HTTP GET; the wasm build uses the PDK, tests inject a fake. +type getter func(url string, headers map[string]string) (status int, body []byte, err error) + +// maxPages bounds pagination; 10 pages x 100 repos is far beyond any repo_limit. +const maxPages = 10 + +// fetchAll pages through a user's (or organization's) public repos, drops +// the ones parseReposPage filters, sorts them, and keeps the first limit. +// All pages are read before truncating so "limit" means the top N overall. +func fetchAll(get getter, user, token string, exclude map[string]bool, limit int) ([]Project, error) { + if user == "" { + return nil, errors.New("github user is empty") + } + // GitHub rejects requests without a User-Agent. + headers := map[string]string{"Accept": "application/vnd.github+json", "User-Agent": "goblog-plugin-projects"} + if token != "" { + headers["Authorization"] = "Bearer " + token + } + var out []Project + for page := 1; page <= maxPages; page++ { + u := fmt.Sprintf("%s/users/%s/repos?type=owner&per_page=%d&page=%d", apiBase, url.PathEscape(user), pageSize, page) + status, body, err := get(u, headers) + if err != nil { + return nil, err + } + if status != 200 { + return nil, fmt.Errorf("github returned HTTP %d: %s", status, truncate(string(body), 200)) + } + n, ps, err := parseReposPage(body, exclude) + if err != nil { + return nil, fmt.Errorf("decode github response: %w", err) + } + out = append(out, ps...) + if n < pageSize { + break + } + } + sortProjects(out) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +// truncate cuts s to at most n runes. +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/fetch_test.go b/fetch_test.go new file mode 100644 index 0000000..b2d92ac --- /dev/null +++ b/fetch_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +// fullPage is pageSize repos, so fetchAll asks for the next page. +func fullPage(prefix string) []byte { + var rs []string + for i := 0; i < pageSize; i++ { + rs = append(rs, fmt.Sprintf(`{"name":"%s%d","stargazers_count":%d}`, prefix, i, i)) + } + return []byte("[" + strings.Join(rs, ",") + "]") +} + +func TestFetchAll(t *testing.T) { + var calls []string + get := func(url string, headers map[string]string) (int, []byte, error) { + calls = append(calls, url) + if headers["Authorization"] != "Bearer tok" || headers["User-Agent"] == "" { + t.Errorf("headers = %v", headers) + } + if strings.HasSuffix(url, "&page=1") { + return 200, fullPage("a"), nil + } + return 200, []byte(`[{"name":"top","stargazers_count":1000},{"name":"skip","stargazers_count":5000}]`), nil + } + ps, err := fetchAll(get, "some user", "tok", parseExclude("skip"), 3) + if err != nil || len(ps) != 3 { + t.Fatalf("got %d projects, %v", len(ps), err) + } + // limit truncates after every page has been read, so the most-starred + // repo on page 2 still comes first. + if ps[0].Name != "top" || ps[1].Name != "a99" { + t.Errorf("order = %+v", ps) + } + if len(calls) != 2 || !strings.HasPrefix(calls[0], "https://api.github.com/users/some%20user/repos?") || !strings.HasSuffix(calls[1], "&page=2") { + t.Errorf("calls = %v", calls) + } + + bad := func(string, map[string]string) (int, []byte, error) { return 403, []byte("rate limited"), nil } + if _, err := fetchAll(bad, "u", "", nil, 5); err == nil || !strings.Contains(err.Error(), "403") { + t.Errorf("403 should error with status, got %v", err) + } + failing := func(string, map[string]string) (int, []byte, error) { return 0, nil, errors.New("net down") } + if _, err := fetchAll(failing, "u", "", nil, 5); err == nil { + t.Error("transport error should propagate") + } + if _, err := fetchAll(get, "", "", nil, 5); err == nil { + t.Error("empty user should error") + } + noToken := func(_ string, headers map[string]string) (int, []byte, error) { + if _, ok := headers["Authorization"]; ok { + t.Errorf("Authorization must be absent without a token: %v", headers) + } + return 200, []byte(`[]`), nil + } + if ps, err := fetchAll(noToken, "u", "", nil, 5); err != nil || len(ps) != 0 { + t.Errorf("no repos: %v %v", ps, err) + } + // full pages forever stop after maxPages + n := 0 + forever := func(string, map[string]string) (int, []byte, error) { n++; return 200, fullPage("x"), nil } + if _, err := fetchAll(forever, "u", "", nil, 1); err != nil || n != maxPages { + t.Errorf("endless pagination: %d calls, %v", n, err) + } +} + +func TestTruncate(t *testing.T) { + if got := truncate("héllo wörld", 5); got != "héllo…" { + t.Errorf("truncate on rune boundary = %q", got) + } + if got := truncate("short", 10); got != "short" { + t.Errorf("truncate no-op = %q", got) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d65cbc6 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/goblogplatform/goblog-plugin-projects + +go 1.25.0 + +toolchain go1.26.1 + +require github.com/extism/go-pdk v1.1.3 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c15d382 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= diff --git a/goblog-plugin.json b/goblog-plugin.json new file mode 100644 index 0000000..cbf002c --- /dev/null +++ b/goblog-plugin.json @@ -0,0 +1,12 @@ +{ + "name": "projects", + "display_name": "Projects", + "description": "A Projects page listing your GitHub repositories, plus any projects you add by hand, cached and refreshed daily.", + "author": "Jason Ernst", + "license": "Apache-2.0", + "runtime": "wasm", + "entry": "plugin.wasm", + "allowed_hosts": ["api.github.com"], + "min_goblog_version": "0.3.0", + "homepage": "https://github.com/goblogplatform/goblog-plugin-projects" +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..315f50b --- /dev/null +++ b/main.go @@ -0,0 +1,239 @@ +//go:build wasip1 + +// Projects is a goblog WebAssembly plugin: it serves a Projects page +// listing a GitHub user's or organization's public repositories plus any +// projects entered by hand in its settings. Repos are served from the +// plugin store; an hourly job re-fetches once the cache is older than +// cache_hours, and the page only fetches itself when the store is empty. +// +// Build: GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm . +// Every export takes JSON on stdin (pdk.Input) and returns JSON (pdk.Output). +// See goblog's README "WebAssembly plugins" for the contract. +package main + +import ( + "encoding/json" + "strings" + "time" + + pdk "github.com/extism/go-pdk" +) + +//go:wasmimport extism:host/user store_get +func hostStoreGet(uint64) uint64 + +//go:wasmimport extism:host/user store_set +func hostStoreSet(uint64, uint64) uint64 + +// storeGet returns the value and whether the key exists: the host returns +// offset 0 (Extism's null) for a missing key. +func storeGet(key string) ([]byte, bool) { + k := pdk.AllocateString(key) + defer k.Free() + off := hostStoreGet(k.Offset()) + if off == 0 { + return nil, false + } + return pdk.ParamBytes(off), true +} + +func storeSet(key string, value []byte) bool { + k := pdk.AllocateString(key) + defer k.Free() + v := pdk.AllocateBytes(value) + defer v.Free() + return hostStoreSet(k.Offset(), v.Offset()) == 0 +} + +// hookInput is the ctx goblog passes to render_page; only settings and the +// sub-path are needed here. +type hookInput struct { + Settings map[string]string `json:"settings"` + Request struct { + SubPath string `json:"sub_path"` + } `json:"request"` +} + +type jobInput struct { + Name string `json:"name"` + Settings map[string]string `json:"settings"` +} + +// setting mirrors goblog's settings JSON shape. +type setting struct { + Key string `json:"key"` + Type string `json:"type"` + Default string `json:"default"` + Label string `json:"label"` + Description string `json:"description"` +} + +func outputJSON(v any) int32 { + if err := pdk.OutputJSON(v); err != nil { + pdk.SetErrorString("encode output: " + err.Error()) + return 1 + } + return 0 +} + +// pdkGet is the getter used in the wasm build: Extism's http_request, +// limited by the host to allowed_hosts. +func pdkGet(url string, headers map[string]string) (int, []byte, error) { + req := pdk.NewHTTPRequest(pdk.MethodGet, url) + for k, v := range headers { + req.SetHeader(k, v) + } + resp := req.Send() + return int(resp.Status()), resp.Body(), nil +} + +//go:wasmexport identity +func identity() int32 { + return outputJSON(map[string]string{"name": "projects", "display_name": "Projects", "version": "1.0.0"}) +} + +//go:wasmexport settings +func settings() int32 { + return outputJSON([]setting{ + {Key: "enabled", Type: "text", Default: "false", Label: "Enabled", Description: "Set to 'true' to enable the Projects page"}, + {Key: "github_user", Type: "text", Default: "", Label: "GitHub User or Organization", Description: "Lists this account's public repositories (forks and archived repos are skipped); leave empty to show only extra projects"}, + {Key: "github_token", Type: "text", Default: "", Label: "GitHub Token", Description: "Optional; raises the API rate limit. Needs no scopes"}, + {Key: "exclude_repos", Type: "text", Default: "", Label: "Exclude Repos", Description: "Comma-separated repository names to hide"}, + {Key: "repo_limit", Type: "text", Default: "30", Label: "Repo Limit", Description: "Maximum number of repositories to show (most-starred first, at most 500)"}, + {Key: "extra_projects", Type: "textarea", Default: "", Label: "Extra Projects", Description: "Shown above the GitHub repos, one per line: Name | URL | Description (URL and description optional)"}, + {Key: "cache_hours", Type: "text", Default: "24", Label: "Cache Hours", Description: "How long fetched repos are reused before the hourly job refreshes them (0 or invalid means the default of 24)"}, + }) +} + +//go:wasmexport pages +func pages() int32 { + return outputJSON([]map[string]any{{"page_type": "projects", "title": "Projects", "slug": "projects", "show_in_nav": true, "nav_order": 25, "description": "Projects and GitHub repositories"}}) +} + +//go:wasmexport jobs +func jobs() int32 { + return outputJSON([]map[string]any{{"name": "refresh", "interval_seconds": 3600}}) +} + +// loadCache reads the stored repos for user; ok is false when absent, +// undecodable or fetched for a different user. +func loadCache(user string) (cachedRepos, bool) { + b, ok := storeGet("repos") + if !ok { + return cachedRepos{}, false + } + var c cachedRepos + if err := json.Unmarshal(b, &c); err != nil || !strings.EqualFold(c.User, user) { + return cachedRepos{}, false + } + return c, true +} + +// lastFailure is the negative cache: when the most recent fetch failed. +type lastFailure struct { + FailedAt time.Time `json:"failed_at"` +} + +func loadLastFailure() time.Time { + b, ok := storeGet("last_failure") + if !ok { + return time.Time{} + } + var f lastFailure + if err := json.Unmarshal(b, &f); err != nil { + return time.Time{} + } + return f.FailedAt +} + +// fetchAndStore fetches the user's repos and replaces the cache. On failure +// it logs, records last_failure and returns false. A transport-level failure +// (DNS, refused connection, TLS) never returns at all: Extism's http_request +// aborts the guest call, which is why render_page only fetches when it has +// nothing to show. +func fetchAndStore(settings map[string]string) ([]Project, bool) { + user := strings.TrimSpace(settings["github_user"]) + limit := parseIntSetting(settings["repo_limit"], 30, maxRepoLimit) + repos, err := fetchAll(pdkGet, user, strings.TrimSpace(settings["github_token"]), parseExclude(settings["exclude_repos"]), limit) + if err != nil { + pdk.Log(pdk.LogWarn, "github fetch failed: "+err.Error()) + b, _ := json.Marshal(lastFailure{FailedAt: time.Now()}) + storeSet("last_failure", b) + return nil, false + } + b, _ := json.Marshal(cachedRepos{User: user, FetchedAt: time.Now(), Repos: repos}) + if !storeSet("repos", b) { + pdk.Log(pdk.LogWarn, "could not store the repos cache") + } + return repos, true +} + +// loadOrFetch is render_page's strategy: serve whatever is cached, fresh or +// stale, and fetch synchronously only when there is no cache at all and no +// fetch failed in the last failureBackoff. +func loadOrFetch(settings map[string]string) ([]Project, bool) { + cache, have := loadCache(strings.TrimSpace(settings["github_user"])) + if have { + return cache.Repos, true + } + if !shouldFetch(have, loadLastFailure(), time.Now()) { + return nil, false + } + return fetchAndStore(settings) +} + +// refreshIfStale is the job's strategy: re-fetch once the cache is older +// than cache_hours (or missing). It has the 120 s job budget and its +// failures are only logged, so it is where slow or failing fetches belong. +func refreshIfStale(settings map[string]string) { + hours := parseIntSetting(settings["cache_hours"], 24, 0) + cache, have := loadCache(strings.TrimSpace(settings["github_user"])) + if staleFor(cache, have, time.Now(), hours) { + fetchAndStore(settings) + } +} + +//go:wasmexport render_page +func renderPage() int32 { + var in hookInput + if err := json.Unmarshal(pdk.Input(), &in); err != nil { + pdk.SetErrorString("render_page: " + err.Error()) + return 1 + } + if in.Request.SubPath != "" { + return outputJSON(map[string]any{}) // no sub-pages: goblog 404s + } + projects := parseExtraProjects(in.Settings["extra_projects"]) + hasUser := strings.TrimSpace(in.Settings["github_user"]) != "" + if !hasUser && len(projects) == 0 { + return outputJSON(map[string]any{"html": notConfiguredHTML}) + } + var notice string + if hasUser { + repos, ok := loadOrFetch(in.Settings) + if ok { + projects = append(projects, repos...) + } else { + notice = unavailableHTML + } + } + if notice != "" && len(projects) == 0 { + return outputJSON(map[string]any{"html": notice}) + } + return outputJSON(map[string]any{"html": notice + renderProjectsHTML(projects)}) +} + +//go:wasmexport run_job +func runJob() int32 { + var in jobInput + if err := json.Unmarshal(pdk.Input(), &in); err != nil { + pdk.SetErrorString("run_job: " + err.Error()) + return 1 + } + if in.Name == "refresh" && strings.TrimSpace(in.Settings["github_user"]) != "" { + refreshIfStale(in.Settings) + } + return outputJSON(map[string]any{}) +} + +func main() {} diff --git a/main_native.go b/main_native.go new file mode 100644 index 0000000..213461b --- /dev/null +++ b/main_native.go @@ -0,0 +1,5 @@ +//go:build !wasip1 + +package main + +func main() {} diff --git a/project.go b/project.go new file mode 100644 index 0000000..65f00ea --- /dev/null +++ b/project.go @@ -0,0 +1,197 @@ +package main + +import ( + "encoding/json" + "html" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +// Project is one entry on the Projects page: a GitHub repository or a +// line from the extra_projects setting. +type Project struct { + Name string `json:"name"` + URL string `json:"url"` + Description string `json:"description"` + Language string `json:"language"` + Stars int `json:"stars"` + PushedAt string `json:"pushed_at"` // RFC 3339; GitHub repos only +} + +type githubRepo struct { + Name string `json:"name"` + HTMLURL string `json:"html_url"` + Description string `json:"description"` + Language string `json:"language"` + Stars int `json:"stargazers_count"` + PushedAt string `json:"pushed_at"` + Fork bool `json:"fork"` + Archived bool `json:"archived"` + Private bool `json:"private"` +} + +// parseReposPage decodes one page of /users/{user}/repos. It returns the +// number of repos on the page (for pagination) and the ones worth listing: +// forks, archived and private repos and names in exclude are dropped. +func parseReposPage(b []byte, exclude map[string]bool) (int, []Project, error) { + var raw []githubRepo + if err := json.Unmarshal(b, &raw); err != nil { + return 0, nil, err + } + var out []Project + for _, r := range raw { + if r.Fork || r.Archived || r.Private || exclude[strings.ToLower(r.Name)] { + continue + } + out = append(out, Project{Name: r.Name, URL: r.HTMLURL, Description: r.Description, Language: r.Language, Stars: r.Stars, PushedAt: r.PushedAt}) + } + return len(raw), out, nil +} + +// sortProjects orders most-starred first, then most recently pushed, then by name. +func sortProjects(ps []Project) { + sort.SliceStable(ps, func(i, j int) bool { + if ps[i].Stars != ps[j].Stars { + return ps[i].Stars > ps[j].Stars + } + if ps[i].PushedAt != ps[j].PushedAt { + return ps[i].PushedAt > ps[j].PushedAt + } + return ps[i].Name < ps[j].Name + }) +} + +// parseExclude turns "a, B ,c" into a case-insensitive set of repo names. +func parseExclude(s string) map[string]bool { + m := map[string]bool{} + for _, n := range strings.Split(s, ",") { + if n = strings.ToLower(strings.TrimSpace(n)); n != "" { + m[n] = true + } + } + return m +} + +// parseExtraProjects reads the extra_projects setting: one project per +// line as "Name | URL | Description", URL and description optional. Blank +// lines and lines starting with # are skipped. +func parseExtraProjects(s string) []Project { + var out []Project + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "|", 3) + for len(parts) < 3 { + parts = append(parts, "") + } + p := Project{Name: strings.TrimSpace(parts[0]), URL: strings.TrimSpace(parts[1]), Description: strings.TrimSpace(parts[2])} + if p.Name != "" { + out = append(out, p) + } + } + return out +} + +// safeHref allows only absolute http(s) URLs into href attributes. +func safeHref(raw string) string { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return "" + } + return html.EscapeString(u.String()) +} + +// renderProjectsHTML lists projects in the same plain markup the scholar +// plugin uses, so it fits any theme. +func renderProjectsHTML(projects []Project) string { + if len(projects) == 0 { + return `

No projects found.

` + } + var b strings.Builder + for _, p := range projects { + b.WriteString(`
`) + if href := safeHref(p.URL); href != "" { + b.WriteString(`
` + html.EscapeString(p.Name) + `
`) + } else { + b.WriteString(`
` + html.EscapeString(p.Name) + `
`) + } + if p.Description != "" { + b.WriteString(`
` + html.EscapeString(p.Description) + `
`) + } + var meta []string + if p.Language != "" { + meta = append(meta, html.EscapeString(p.Language)) + } + if p.Stars > 0 { + meta = append(meta, "★ "+strconv.Itoa(p.Stars)) + } + if len(meta) > 0 { + b.WriteString(`
` + strings.Join(meta, " · ") + `
`) + } + b.WriteString(`
`) + } + return b.String() +} + +// cachedRepos is what the plugin keeps under the store key "repos". User +// records whose repos these are, so changing github_user drops the cache. +type cachedRepos struct { + User string `json:"user"` + FetchedAt time.Time `json:"fetched_at"` + Repos []Project `json:"repos"` +} + +func (c cachedRepos) fresh(now time.Time, cacheHours int) bool { + if c.FetchedAt.IsZero() { + return false + } + return now.Sub(c.FetchedAt) < time.Duration(cacheHours)*time.Hour +} + +// failureBackoff is how long render_page avoids a synchronous fetch after +// one has failed, so a GitHub outage does not turn every page view into a +// slow (or, on transport errors, aborted) request. +const failureBackoff = 10 * time.Minute + +// shouldFetch decides whether render_page fetches synchronously: only when +// there is no cache at all (a stale cache is served as-is; the job refreshes +// it) and no fetch has failed within failureBackoff. +func shouldFetch(have bool, lastFailure, now time.Time) bool { + if have { + return false + } + return lastFailure.IsZero() || now.Sub(lastFailure) >= failureBackoff +} + +// staleFor decides whether the refresh job fetches: when there is no cache +// or it is older than cacheHours. +func staleFor(cache cachedRepos, have bool, now time.Time, cacheHours int) bool { + return !have || !cache.fresh(now, cacheHours) +} + +// maxRepoLimit caps repo_limit: more than this is never useful on a page +// and bounds the render_page payload. +const maxRepoLimit = 500 + +// parseIntSetting reads a positive integer setting with a default, capped +// at max when max > 0. +func parseIntSetting(s string, def, max int) int { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n <= 0 { + return def + } + if max > 0 && n > max { + return max + } + return n +} + +const ( + unavailableHTML = `` + notConfiguredHTML = `` +) diff --git a/project_test.go b/project_test.go new file mode 100644 index 0000000..77e96be --- /dev/null +++ b/project_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +const reposJSON = `[ + {"name":"tool","html_url":"https://github.com/u/tool","description":"A & more","language":"Go","stargazers_count":12,"pushed_at":"2026-01-01T00:00:00Z"}, + {"name":"forked","html_url":"https://github.com/u/forked","fork":true,"stargazers_count":99}, + {"name":"old","html_url":"https://github.com/u/old","archived":true,"stargazers_count":99}, + {"name":"Hidden","html_url":"https://github.com/u/Hidden","stargazers_count":99}, + {"name":"bad","html_url":"javascript:alert(1)","description":null,"language":null} +]` + +func TestParseReposPage(t *testing.T) { + n, ps, err := parseReposPage([]byte(reposJSON), parseExclude(" hidden ,")) + if err != nil { + t.Fatal(err) + } + if n != 5 || len(ps) != 2 { + t.Fatalf("n=%d projects=%+v", n, ps) + } + p := ps[0] + if p.Name != "tool" || p.URL != "https://github.com/u/tool" || p.Language != "Go" || p.Stars != 12 || p.PushedAt != "2026-01-01T00:00:00Z" { + t.Errorf("project = %+v", p) + } + if _, _, err := parseReposPage([]byte("{"), nil); err == nil { + t.Error("bad JSON should error") + } +} + +func TestSortProjects(t *testing.T) { + ps := []Project{ + {Name: "b", Stars: 1, PushedAt: "2020"}, + {Name: "c", Stars: 5}, + {Name: "a", Stars: 1, PushedAt: "2021"}, + {Name: "d", Stars: 1, PushedAt: "2021"}, + } + sortProjects(ps) + if got := ps[0].Name + ps[1].Name + ps[2].Name + ps[3].Name; got != "cadb" { + t.Errorf("order = %s, want cadb (stars desc, pushed desc, name asc)", got) + } +} + +func TestParseExtraProjects(t *testing.T) { + ps := parseExtraProjects("# comment\n\n Site | https://example.com | My site | with pipe \nJust a name\n | https://nameless.example\r\nTwo | https://two.example\n") + if len(ps) != 3 { + t.Fatalf("got %+v", ps) + } + if ps[0] != (Project{Name: "Site", URL: "https://example.com", Description: "My site | with pipe"}) { + t.Errorf("full line = %+v", ps[0]) + } + if ps[1] != (Project{Name: "Just a name"}) { + t.Errorf("name only = %+v", ps[1]) + } + if ps[2] != (Project{Name: "Two", URL: "https://two.example"}) { + t.Errorf("CRLF/no description = %+v", ps[2]) + } +} + +func TestRenderProjectsHTML(t *testing.T) { + if got := renderProjectsHTML(nil); got != "

No projects found.

" { + t.Errorf("empty = %q", got) + } + _, ps, _ := parseReposPage([]byte(reposJSON), parseExclude("hidden")) + out := renderProjectsHTML(ps) + for _, want := range []string{`href="https://github.com/u/tool"`, "A <tool> & more", "Go · ★ 12", "
bad
"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in\n%s", want, out) + } + } + if strings.Contains(out, "javascript:") { + t.Errorf("unsafe href rendered:\n%s", out) + } +} + +func TestCacheDecisions(t *testing.T) { + now := time.Now() + if !shouldFetch(false, time.Time{}, now) || shouldFetch(true, time.Time{}, now) { + t.Error("fetch only without a cache") + } + if shouldFetch(false, now.Add(-time.Minute), now) || !shouldFetch(false, now.Add(-failureBackoff), now) { + t.Error("failure backoff not honoured") + } + c := cachedRepos{FetchedAt: now.Add(-2 * time.Hour)} + if staleFor(c, true, now, 24) || !staleFor(c, true, now, 1) || !staleFor(c, false, now, 24) || !staleFor(cachedRepos{}, true, now, 24) { + t.Error("staleFor wrong") + } + if parseIntSetting("", 30, 500) != 30 || parseIntSetting("0", 30, 500) != 30 || parseIntSetting("9999", 30, 500) != 500 || parseIntSetting(" 7 ", 30, 0) != 7 { + t.Error("parseIntSetting wrong") + } +}