From 1885488d05d08c054862cf0b5516d16dd077d7a1 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 1 Oct 2026 12:46:42 +0200 Subject: [PATCH] =?UTF-8?q?Serveur=20Go=20:=20recherche=20GitHub=20par=20c?= =?UTF-8?q?rit=C3=A8res?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/search traduit les filtres en requête GitHub (langage, étoiles, activité, création, licence, topic, forks, archivés, good first issues) - cache mémoire des recherches identiques (CACHE_TTL) et suivi du quota - /api/status, /healthz, authentification basique optionnelle - Dockerfile et docker-compose.yml Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 7 ++ .gitignore | 3 + Dockerfile | 19 +++++ api.go | 89 +++++++++++++++++++++ docker-compose.yml | 12 +++ go.mod | 3 + main.go | 95 ++++++++++++++++++++++ query.go | 192 +++++++++++++++++++++++++++++++++++++++++++++ query_test.go | 52 ++++++++++++ 9 files changed, 472 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 api.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 main.go create mode 100644 query.go create mode 100644 query_test.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cb8012a --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copy to .env and adjust. +HTTP_PORT=8080 +# GitHub token (fine-grained, no permission needed for public repositories). +GITHUB_TOKEN= +CACHE_TTL=5m +AUTH_USER= +AUTH_PASS= diff --git a/.gitignore b/.gitignore index 5b90e79..c23f2ff 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ go.work.sum # env file .env +# Binary +/searchgit + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3dee1b6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Image versions are pinned: update them on purpose. + +# ---- Build ---- +FROM golang:1.27.1-alpine3.24 AS build +WORKDIR /src +COPY go.mod ./ +COPY *.go ./ +COPY web ./web +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/searchgit . + +# ---- Final image ---- +FROM alpine:3.24.2 +RUN apk add --no-cache ca-certificates \ + && adduser -D -H -u 10001 searchgit +COPY --from=build /out/searchgit /usr/local/bin/searchgit +USER searchgit +EXPOSE 8080/tcp +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 +ENTRYPOINT ["/usr/local/bin/searchgit"] diff --git a/api.go b/api.go new file mode 100644 index 0000000..44e0035 --- /dev/null +++ b/api.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" +) + +type API struct { + gh *GitHub +} + +func (a *API) Routes(mux *http.ServeMux) { + mux.HandleFunc("GET /healthz", a.health) + mux.HandleFunc("GET /api/search", a.search) + mux.HandleFunc("GET /api/status", a.status) +} + +// codedError carries a stable code that the UI translates. +type codedError struct { + code string + msg string + detail string +} + +func (e *codedError) Error() string { + if e.detail != "" { + return e.msg + ": " + e.detail + } + return e.msg +} + +// GET /api/search?q=&language=&stars=&maxstars=&minforks=&topic=&license= +// &pushed=&created=&in=&archived=1&forks=1&goodfirst=1&sort=&order=&page=&per_page= +func (a *API) search(w http.ResponseWriter, r *http.Request) { + c, err := ParseCriteria(r.URL.Query()) + if err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + res, err := a.gh.Search(r.Context(), c) + if err != nil { + status := http.StatusBadGateway + var ce *codedError + if errors.As(err, &ce) { + switch ce.code { + case "rate_limited": + status = http.StatusTooManyRequests + case "bad_query": + status = http.StatusBadRequest + } + } + writeErr(w, status, err) + return + } + writeJSON(w, http.StatusOK, res) +} + +// GET /api/status: token configured and last known search quota (footer). +func (a *API) status(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "token": a.gh.HasToken(), + "rate": a.gh.Rate(), + }) +} + +func (a *API) health(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// writeErr returns {"error": "...", "code": "...", "detail": "..."}; the UI +// translates known codes and falls back to the English message. +func writeErr(w http.ResponseWriter, status int, err error) { + body := map[string]string{"error": err.Error()} + var ce *codedError + if errors.As(err, &ce) { + body["code"] = ce.code + if ce.detail != "" { + body["detail"] = ce.detail + } + } + writeJSON(w, status, body) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..73191fc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + searchgit: + build: . + container_name: searchgit + restart: unless-stopped + ports: + - "${HTTP_PORT:-8080}:8080" + environment: + GITHUB_TOKEN: ${GITHUB_TOKEN:-} # optional: 30 searches/min instead of 10 + CACHE_TTL: ${CACHE_TTL:-5m} # identical searches are served from memory + AUTH_USER: ${AUTH_USER:-} # leave empty to disable authentication + AUTH_PASS: ${AUTH_PASS:-} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8c4224d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module searchgit + +go 1.23 diff --git a/main.go b/main.go new file mode 100644 index 0000000..f7d41c7 --- /dev/null +++ b/main.go @@ -0,0 +1,95 @@ +// Searchgit: search GitHub repositories with criteria, from a web interface. +package main + +import ( + "context" + "crypto/subtle" + "embed" + "errors" + "io/fs" + "log" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +//go:embed web +var webFS embed.FS + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func getenvDuration(key string, def time.Duration) time.Duration { + if d, err := time.ParseDuration(os.Getenv(key)); err == nil && d >= 0 { + return d + } + return def +} + +func main() { + httpAddr := getenv("HTTP_ADDR", ":8080") + token := os.Getenv("GITHUB_TOKEN") + gh := NewGitHub(getenv("GITHUB_API", "https://api.github.com"), token, getenvDuration("CACHE_TTL", 5*time.Minute)) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + static, err := fs.Sub(webFS, "web") + if err != nil { + log.Fatal(err) + } + mux := http.NewServeMux() + (&API{gh: gh}).Routes(mux) + mux.Handle("GET /", http.FileServer(http.FS(static))) + + srv := &http.Server{ + Addr: httpAddr, + Handler: basicAuth(os.Getenv("AUTH_USER"), os.Getenv("AUTH_PASS"), mux), + ReadHeaderTimeout: 10 * time.Second, + BaseContext: func(net.Listener) context.Context { return ctx }, + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + if token == "" { + log.Printf("GITHUB_TOKEN not set: GitHub allows only 10 searches per minute") + } + log.Printf("web UI on %s", httpAddr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("http: %v", err) + } + log.Println("shutdown complete") +} + +// basicAuth protects the UI when AUTH_USER is set (except /healthz). +func basicAuth(user, pass string, next http.Handler) http.Handler { + if user == "" { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + next.ServeHTTP(w, r) + return + } + u, p, ok := r.BasicAuth() + if !ok || + subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 || + subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 { + w.Header().Set("WWW-Authenticate", `Basic realm="searchgit"`) + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/query.go b/query.go new file mode 100644 index 0000000..0ebc431 --- /dev/null +++ b/query.go @@ -0,0 +1,192 @@ +package main + +import ( + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +// Criteria are the search filters chosen in the UI. They are turned into a +// GitHub search query (https://docs.github.com/search-github/searching-on-github/searching-for-repositories). +type Criteria struct { + Text string // free text, may already contain GitHub qualifiers (topic:cli user:foo…) + Language string + MinStars int + MaxStars int + MinForks int + Topic string + License string // SPDX key in lower case: mit, apache-2.0… + PushedIn time.Duration // last push less than this ago (0 = any) + CreatedIn time.Duration // created less than this ago (0 = any) + In string // where the text is searched: "", "name", "description", "readme" + Archived bool // include archived repositories + Forks bool // include forks + GoodFirst bool // has issues labeled "good first issue" + Sort string // "", "stars", "forks", "updated", "help-wanted-issues" + Order string // "desc" (default) or "asc" + Page int + PerPage int +} + +var ( + sorts = map[string]bool{"": true, "stars": true, "forks": true, "updated": true, "help-wanted-issues": true} + ins = map[string]bool{"": true, "name": true, "description": true, "readme": true} + safeValue = regexp.MustCompile(`^[\w.+#-]+$`) // topic, license + safeLang = regexp.MustCompile(`^[\w.+# -]+$`) // "Jupyter Notebook", "C#", "C++" + durations = map[string]time.Duration{ + "1w": 7 * 24 * time.Hour, "1m": 30 * 24 * time.Hour, "3m": 91 * 24 * time.Hour, + "6m": 182 * 24 * time.Hour, "1y": 365 * 24 * time.Hour, "2y": 730 * 24 * time.Hour, + "5y": 1826 * 24 * time.Hour, + } +) + +// ParseCriteria reads the criteria from the /api/search query string. +func ParseCriteria(v url.Values) (Criteria, error) { + c := Criteria{ + Text: strings.TrimSpace(v.Get("q")), + Language: strings.TrimSpace(v.Get("language")), + Topic: strings.ToLower(strings.TrimSpace(v.Get("topic"))), + License: strings.ToLower(strings.TrimSpace(v.Get("license"))), + In: v.Get("in"), + Archived: v.Get("archived") == "1", + Forks: v.Get("forks") == "1", + GoodFirst: v.Get("goodfirst") == "1", + Sort: v.Get("sort"), + Order: v.Get("order"), + Page: 1, + PerPage: 30, + } + var err error + if c.MinStars, err = intParam(v, "stars"); err != nil { + return c, err + } + if c.MaxStars, err = intParam(v, "maxstars"); err != nil { + return c, err + } + if c.MinForks, err = intParam(v, "minforks"); err != nil { + return c, err + } + if p, err := intParam(v, "page"); err != nil { + return c, err + } else if p > 0 { + c.Page = p + } + if n, err := intParam(v, "per_page"); err != nil { + return c, err + } else if n > 0 { + c.PerPage = min(n, 100) + } + if c.PushedIn, err = durationParam(v, "pushed"); err != nil { + return c, err + } + if c.CreatedIn, err = durationParam(v, "created"); err != nil { + return c, err + } + if c.Language != "" && !safeLang.MatchString(c.Language) { + return c, &codedError{code: "bad_param", msg: "invalid language", detail: "language"} + } + for name, val := range map[string]string{"topic": c.Topic, "license": c.License} { + if val != "" && !safeValue.MatchString(val) { + return c, &codedError{code: "bad_param", msg: "invalid " + name, detail: name} + } + } + if !sorts[c.Sort] { + return c, &codedError{code: "bad_param", msg: "invalid sort", detail: "sort"} + } + if !ins[c.In] { + return c, &codedError{code: "bad_param", msg: "invalid in", detail: "in"} + } + if c.Order != "asc" { + c.Order = "desc" + } + // GitHub never returns more than the first 1000 results. + if c.Page*c.PerPage > 1000 { + return c, &codedError{code: "too_far", msg: "GitHub only returns the first 1000 results"} + } + return c, nil +} + +func intParam(v url.Values, key string) (int, error) { + s := strings.TrimSpace(v.Get(key)) + if s == "" { + return 0, nil + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return 0, &codedError{code: "bad_param", msg: "invalid " + key, detail: key} + } + return n, nil +} + +func durationParam(v url.Values, key string) (time.Duration, error) { + s := v.Get(key) + if s == "" { + return 0, nil + } + d, ok := durations[s] + if !ok { + return 0, &codedError{code: "bad_param", msg: "invalid " + key, detail: key} + } + return d, nil +} + +// Query builds the GitHub "q" parameter. now is passed in for tests. +func (c Criteria) Query(now time.Time) string { + var parts []string + if c.Text != "" { + parts = append(parts, c.Text) + } + if c.In != "" && c.Text != "" { + parts = append(parts, "in:"+c.In) + } + if c.Language != "" { + parts = append(parts, "language:"+quoteIfNeeded(c.Language)) + } + if c.Topic != "" { + parts = append(parts, "topic:"+c.Topic) + } + if c.License != "" { + parts = append(parts, "license:"+c.License) + } + switch { + case c.MinStars > 0 && c.MaxStars > 0: + parts = append(parts, fmt.Sprintf("stars:%d..%d", c.MinStars, c.MaxStars)) + case c.MinStars > 0: + parts = append(parts, fmt.Sprintf("stars:>=%d", c.MinStars)) + case c.MaxStars > 0: + parts = append(parts, fmt.Sprintf("stars:<=%d", c.MaxStars)) + } + if c.MinForks > 0 { + parts = append(parts, fmt.Sprintf("forks:>=%d", c.MinForks)) + } + if c.PushedIn > 0 { + parts = append(parts, "pushed:>="+now.Add(-c.PushedIn).UTC().Format("2006-01-02")) + } + if c.CreatedIn > 0 { + parts = append(parts, "created:>="+now.Add(-c.CreatedIn).UTC().Format("2006-01-02")) + } + if !c.Archived { + parts = append(parts, "archived:false") + } + if c.Forks { + parts = append(parts, "fork:true") + } + if c.GoodFirst { + parts = append(parts, "good-first-issues:>0") + } + // GitHub refuses an empty query: search everything with at least one star. + if c.Text == "" && c.Language == "" && c.Topic == "" && c.License == "" && c.MinStars == 0 && c.MaxStars == 0 { + parts = append(parts, "stars:>=1") + } + return strings.Join(parts, " ") +} + +func quoteIfNeeded(s string) string { + if strings.ContainsAny(s, " ") { + return `"` + s + `"` + } + return s +} diff --git a/query_test.go b/query_test.go new file mode 100644 index 0000000..b4a83cc --- /dev/null +++ b/query_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "net/url" + "testing" + "time" +) + +func TestQuery(t *testing.T) { + now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + tests := []struct { + params string + want string + }{ + {"", "archived:false stars:>=1"}, + {"q=log+viewer&in=name", "log viewer in:name archived:false"}, + {"language=Go&stars=100", "language:Go stars:>=100 archived:false"}, + {"stars=10&maxstars=500&minforks=5", "stars:10..500 forks:>=5 archived:false"}, + {"topic=CLI&license=MIT&archived=1&forks=1", "topic:cli license:mit fork:true"}, + {"q=syslog&pushed=1y&created=1m&goodfirst=1", "syslog pushed:>=2025-10-01 created:>=2026-09-01 archived:false good-first-issues:>0"}, + } + for _, tt := range tests { + v, _ := url.ParseQuery(tt.params) + c, err := ParseCriteria(v) + if err != nil { + t.Fatalf("%q: %v", tt.params, err) + } + if got := c.Query(now); got != tt.want { + t.Errorf("%q:\n got %q\n want %q", tt.params, got, tt.want) + } + } +} + +func TestParseCriteriaErrors(t *testing.T) { + for _, p := range []string{ + "stars=-1", "stars=abc", "pushed=3d", "sort=name", "in=code", + "language=go%20lang%3Arm", "topic=a:b", "page=40&per_page=30", + } { + v, _ := url.ParseQuery(p) + if _, err := ParseCriteria(v); err == nil { + t.Errorf("%q: expected an error", p) + } + } +} + +func TestPerPageCap(t *testing.T) { + v, _ := url.ParseQuery("per_page=500") + c, err := ParseCriteria(v) + if err != nil || c.PerPage != 100 { + t.Fatalf("per_page=500: got %d, %v", c.PerPage, err) + } +}