/* * distrohop - A utility for correlating and identifying equivalent software * packages across different Linux distributions * * Copyright (C) 2025 Elara Ivy * * This file is part of distrohop. * * distrohop is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * distrohop is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with distrohop. If not, see . */ package main import ( "embed" "encoding/json" "errors" "fmt" "io/fs" "log/slog" "net/http" "os" "os/signal" "path/filepath" "time" "github.com/cockroachdb/pebble" "github.com/go-chi/chi/v5" "github.com/go-chi/httprate" "github.com/go-co-op/gocron/v2" "go.elara.ws/distrohop/internal/config" "go.elara.ws/distrohop/internal/index" "go.elara.ws/distrohop/internal/pull" "go.elara.ws/distrohop/internal/store" "go.elara.ws/distrohop/internal/store/cached" "go.elara.ws/distrohop/internal/store/combined" "go.elara.ws/loggers" "go.elara.ws/salix" ) //go:embed templates var tmpls embed.FS //go:embed assets var assets embed.FS func main() { log := slog.New(loggers.NewPretty(os.Stderr, loggers.Options{Level: slog.LevelDebug})) cfg, err := config.Load() if err != nil { log.Error("Error loading configuration", slog.Any("error", err)) os.Exit(1) } dataDir, err := userDataDir() if err != nil { log.Error("Error getting data directory", slog.Any("error", err)) os.Exit(1) } dataDir = filepath.Join(dataDir, "distrohop") stores := map[string]store.ReadOnly{} // Create a scheduler for repo refresh tasks sched, err := gocron.NewScheduler( gocron.WithLocation(time.Local), ) if err != nil { log.Error("Error creating scheduler", slog.Any("error", err)) os.Exit(1) } sched.Start() defer sched.Shutdown() for _, repo := range cfg.Repos { // Create a combined store for the repo cs := combined.New() // Create a cached store for the combined store stores[repo.Name] = cached.New(cs, time.Hour, 10*time.Minute) for _, repoName := range repo.Repos { for _, arch := range repo.Architectures { dbPath := filepath.Join(dataDir, repo.Name, repo.Version, repoName, arch, "db") // Open a store for a specific index within a repo s, err := store.Open(dbPath) if err == nil { // Add the index store to the combined store for the repo cs.Add(s) } else { log.Error("Error opening database", slog.Any("error", err)) os.Exit(1) } // Schedule a refresh job for the repo if job := scheduleRefresh(log, s, sched, repo, repoName, arch); job != nil { // Run the refresh job immediately on startup if err := job.RunNow(); err != nil { log.Warn("Error executing repo refresh task on startup", slog.String("repo", repoName), slog.Any("error", err)) } } } } } tmplFS, err := fs.Sub(tmpls, "templates") if err != nil { log.Error("Error getting templates subdirectory", slog.Any("error", err)) os.Exit(1) } ns := salix.New(). WithEscapeHTML(true). WithWriteOnSuccess(true). WithTagMap(map[string]salix.Tag{ "icon": salix.FSTag{ FS: assets, PathPrefix: "assets/icons", Extension: ".svg", }, }). WithVarMap(map[string]any{ "sprintf": fmt.Sprintf, }) err = ns.ParseFSGlob(tmplFS, "*") if err != nil { log.Error("Error parsing templates", slog.Any("error", err)) os.Exit(1) } mux := chi.NewMux() mux.Handle("/assets/*", http.FileServer(http.FS(assets))) mux.Get("/", handleErrGUI(ns, func(w http.ResponseWriter, r *http.Request) error { return ns.ExecuteTemplate(w, "home.html", map[string]any{"cfg": cfg}) })) mux.Get("/about", handleErrGUI(ns, func(w http.ResponseWriter, r *http.Request) error { return ns.ExecuteTemplate(w, "about.html", nil) })) mux.Get("/pkg/{repo}/{package}", handleErrGUI(ns, func(w http.ResponseWriter, r *http.Request) error { repo := chi.URLParam(r, "repo") s, ok := stores[repo] if !ok { return fmt.Errorf("no such repo: %q", repo) } pkgName := chi.URLParam(r, "package") pkg, err := s.GetPkg(pkgName) if errors.Is(err, pebble.ErrNotFound) { return fmt.Errorf("no such package: %q", pkgName) } else if err != nil { return err } return ns.ExecuteTemplate(w, "package.html", map[string]any{ "inRepo": repo, "pkg": pkg, }) })) mux.Handle("/suggestions", handleErrJSON(func(w http.ResponseWriter, r *http.Request) error { if r.Method != http.MethodGet { return httpError{fmt.Errorf("method %s not allowed", r.Method), http.StatusMethodNotAllowed} } repo := r.URL.Query().Get("repo") s, ok := stores[repo] if !ok { return httpError{fmt.Errorf("no such repo: %q", repo), http.StatusNotFound} } pkgs, err := s.GetPkgNamesByPrefix(r.URL.Query().Get("input"), 10) if err != nil { return err } return json.NewEncoder(w).Encode(pkgs) })) limiter := httprate.Limit( 10, 10*time.Second, httprate.WithKeyFuncs(httprate.KeyByRealIP), httprate.WithLimitHandler(handleErrGUI(ns, func(w http.ResponseWriter, r *http.Request) error { return httpError{errors.New("You've made too many requests. Please try again later."), http.StatusTooManyRequests} })), ) mux.With(limiter).Route("/search", func(search chi.Router) { search.Get("/tags", handleErrGUI(ns, func(w http.ResponseWriter, r *http.Request) error { query := r.URL.Query() tags := query["tag"] inRepo := query.Get("in") in, ok := stores[inRepo] if !ok { return httpError{fmt.Errorf("no such repo: %q", inRepo), http.StatusNotFound} } results, latency, err := in.Search(tags) if errors.Is(err, store.ErrInvalidTag) { return httpError{err, http.StatusBadRequest} } else if err != nil { return err } return ns.ExecuteTemplate(w, "results.html", map[string]any{ "results": results, "fromRepo": "", "inRepo": inRepo, "tags": tags, "procTime": latency, }) })) search.Get("/pkg", handleErrGUI(ns, func(w http.ResponseWriter, r *http.Request) error { query := r.URL.Query() inRepo := query.Get("in") in, ok := stores[inRepo] if !ok { return httpError{fmt.Errorf("no such repo: %q", inRepo), http.StatusNotFound} } fromRepo := query.Get("from") from, ok := stores[fromRepo] if !ok { return httpError{fmt.Errorf("no such repo: %q", fromRepo), http.StatusNotFound} } pkgName := query.Get("pkg") pkg, err := from.GetPkg(pkgName) if err != nil { return err } results, latency, err := in.Search(pkg.Tags) if err != nil { return err } return ns.ExecuteTemplate(w, "results.html", map[string]any{ "results": results, "fromRepo": fromRepo, "inRepo": inRepo, "pkgName": pkgName, "procTime": latency, }) })) }) mux.NotFound(handleErrGUI(ns, func(w http.ResponseWriter, r *http.Request) error { return httpError{errors.New("page not found"), http.StatusNotFound} })) mux.MethodNotAllowed(handleErrGUI(ns, func(w http.ResponseWriter, r *http.Request) error { return httpError{fmt.Errorf("method %s not allowed", r.Method), http.StatusMethodNotAllowed} })) srv := &http.Server{ Addr: ":8080", Handler: mux, } ch := make(chan os.Signal, 1) signal.Notify(ch, os.Interrupt) go handleShutdown(ch, log, srv, sched) log.Info("Starting HTTP server", slog.Int("port", 8080)) srv.ListenAndServe() } // handleShutdown handles a shutdown signal, such as an OS interrupt func handleShutdown(ch chan os.Signal, log *slog.Logger, srv *http.Server, sched gocron.Scheduler) { sig := <-ch log.Info("Shutting down server", slog.String("signal", sig.String())) srv.Shutdown(nil) sched.Shutdown() } // scheduleRefresh schedules a job to refresh a repo index database func scheduleRefresh(log *slog.Logger, s *store.Store, sched gocron.Scheduler, repo config.Repo, repoName, arch string) (job gocron.Job) { var err error job, err = sched.NewJob( gocron.CronJob(repo.RefreshSchedule, true), gocron.NewTask(func() { opts := pull.Options{ BaseURL: repo.BaseURL, Version: repo.Version, Repo: repoName, Architecture: arch, ProgressFunc: func(title string, received, total int64) { log.Debug( fmt.Sprintf("[%s] download", title), slog.Int64("recvd", received), slog.Int64("total", total), ) }, } importer, err := index.GetImporter(repo.Type) if err != nil { log.Error("Error getting importer", slog.Any("error", err)) return } log.Info( "Pulling repo", slog.String("name", repo.Name), slog.String("version", repo.Version), slog.String("repo", repoName), slog.String("arch", arch), ) err = pull.Pull(opts, s, importer) if err != nil && !errors.Is(err, pull.ErrUpToDate) { log.Warn("Error pulling repository", slog.String("repo", repoName), slog.Any("error", err)) } nextRun, err := job.NextRun() if err != nil { return } log.Info( fmt.Sprintf( "Next refresh scheduled for %s", nextRun.Format(time.RFC1123), ), slog.String("name", repo.Name), slog.String("version", repo.Version), slog.String("repo", repoName), slog.String("arch", arch), ) }), ) if err != nil { log.Warn("Error scheduling repo refresh task", slog.String("repo", repoName), slog.Any("error", err)) } return job } // userDataDir returns the directory where distrohop should store its indices func userDataDir() (string, error) { if os.Getenv("RUNNING_IN_DOCKER") == "true" { return "/data", nil } if dir, ok := os.LookupEnv("XDG_DATA_HOME"); ok { return dir, nil } home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".local/share"), nil }