package main import ( "context" "embed" "flag" "fmt" "io/fs" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" "software-station/internal/api" "software-station/internal/config" "software-station/internal/security" "software-station/internal/stats" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/go-chi/httprate" "github.com/unrolled/secure" ) //go:embed all:frontend/build var frontendBuild embed.FS var ( giteaToken string giteaServer string configPath string ) func main() { flag.StringVar(&giteaToken, "t", os.Getenv("GITEA_TOKEN"), "Gitea API Token") flag.StringVar(&giteaServer, "s", getEnv("GITEA_SERVER", "https://git.quad4.io"), "Gitea Server URL") flag.StringVar(&configPath, "c", getEnv("CONFIG_PATH", config.DefaultConfigPath), "Path to software.txt (local or remote)") uaBlocklistPath := flag.String("ua-blocklist", getEnv("UA_BLOCKLIST_PATH", "ua-blocklist.txt"), "Path to ua-blocklist.txt (optional)") port := flag.String("p", getEnv("PORT", "8080"), "Server port") isProd := flag.Bool("prod", os.Getenv("NODE_ENV") == "production", "Run in production mode") updateInterval := flag.Duration("u", 1*time.Hour, "Software update interval") flag.Parse() statsService := stats.NewService(stats.DefaultHashesFile) statsService.LoadHashes() statsService.Start() defer statsService.Stop() botBlocker := security.NewBotBlocker(*uaBlocklistPath) initialSoftware := config.LoadSoftware(configPath, giteaServer, giteaToken) apiServer := api.NewServer(giteaToken, initialSoftware, statsService) config.StartBackgroundUpdater(configPath, giteaServer, giteaToken, *updateInterval, apiServer.UpdateSoftwareList) r := chi.NewRouter() allowedOriginsStr := getEnv("ALLOWED_ORIGINS", "*") if allowedOriginsStr == "" { allowedOriginsStr = "*" } allowedOrigins := strings.Split(allowedOriginsStr, ",") r.Use(cors.Handler(cors.Options{ AllowedOrigins: allowedOrigins, AllowedMethods: []string{"GET", "POST", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Account-Number"}, ExposedHeaders: []string{"Link"}, AllowCredentials: true, MaxAge: 300, })) secureMiddleware := secure.New(secure.Options{ FrameDeny: true, ContentTypeNosniff: true, BrowserXssFilter: true, ContentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';", IsDevelopment: !*isProd, }) r.Use(secureMiddleware.Handler) r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Compress(api.CompressionLevel)) r.Use(security.SecurityMiddleware(statsService, botBlocker)) r.Use(httprate.Limit( security.GlobalRateLimit, security.GlobalRateWindow, httprate.WithKeyFuncs(func(r *http.Request) (string, error) { return security.GetRequestFingerprint(r, statsService), nil }), )) r.Route("/api", func(r chi.Router) { r.Use(httprate.Limit( security.APIRateLimit, security.APIRateWindow, httprate.WithKeyFuncs(func(r *http.Request) (string, error) { return security.GetRequestFingerprint(r, statsService), nil }), )) r.Get("/software", apiServer.APISoftwareHandler) r.Get("/download", apiServer.DownloadProxyHandler) r.Get("/avatar", apiServer.AvatarHandler) r.Get("/stats", statsService.APIStatsHandler) r.Get("/legal", apiServer.LegalHandler) r.Get("/rss", apiServer.RSSHandler) }) contentStatic, err := fs.Sub(frontendBuild, "frontend/build") if err != nil { log.Fatal(err) } staticHandler := http.FileServer(http.FS(contentStatic)) r.Get("/*", func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if path == "/" { path = "index.html" } else { path = strings.TrimPrefix(path, "/") } f, err := contentStatic.Open(path) if err != nil { if strings.HasPrefix(r.URL.Path, "/api") { http.Error(w, "Not Found", http.StatusNotFound) return } indexData, err := fs.ReadFile(contentStatic, "index.html") if err != nil { http.Error(w, "Index not found", http.StatusInternalServerError) return } http.ServeContent(w, r, "index.html", time.Unix(0, 0), strings.NewReader(string(indexData))) return } if err := f.Close(); err != nil { log.Printf("Error closing static file: %v", err) } staticHandler.ServeHTTP(w, r) }) server := &http.Server{ Addr: ":" + *port, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, Handler: r, } done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { fmt.Printf("Server starting on http://localhost:%s (Gitea: %s, Prod: %v)\n", *port, giteaServer, *isProd) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Printf("Listen error: %s\n", err) done <- syscall.SIGTERM } }() <-done fmt.Println("\nServer stopping...") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { log.Fatalf("Server shutdown failed: %+v", err) } fmt.Println("Server exited properly") } func getEnv(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { return value } return fallback }