// 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) }) }