A search can now be saved with a schedule (every N hours, daily, or weekly at a given day and time). An internal scheduler runs it and keeps a snapshot of the results on a /data volume (JSON files, no new dependency). The new Watch tab lists the scheduled searches; the review page of each one shows, for any run of its history, the repositories never seen before and the ones that gained the most stars since the previous run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
112 lines
2.8 KiB
Go
112 lines
2.8 KiB
Go
// 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"
|
|
"strconv"
|
|
"syscall"
|
|
"time"
|
|
_ "time/tzdata" // TZ works in the minimal Docker image
|
|
)
|
|
|
|
//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 getenvInt(key string, def int) int {
|
|
if n, err := strconv.Atoi(os.Getenv(key)); err == nil && n >= 0 {
|
|
return n
|
|
}
|
|
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)
|
|
}
|
|
store, err := OpenStore(getenv("DATA_DIR", "data"), getenvInt("HISTORY_KEEP", 100))
|
|
if err != nil {
|
|
log.Fatalf("data: %v", err)
|
|
}
|
|
sched := NewScheduler(store, gh)
|
|
sched.Start(ctx)
|
|
|
|
mux := http.NewServeMux()
|
|
(&API{gh: gh, store: store, sched: sched}).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, data in %s, time zone %s", httpAddr, getenv("DATA_DIR", "data"), time.Local)
|
|
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)
|
|
})
|
|
}
|