distrohop/main.go

371 lines
10 KiB
Go
Raw Normal View History

2025-02-12 19:33:11 -08:00
/*
* distrohop - A utility for correlating and identifying equivalent software
* packages across different Linux distributions
*
* Copyright (C) 2025 Elara Ivy <elara@elara.ws>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 if err != nil {
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
}