604 lines
18 KiB
Go
604 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"caileb/middleware"
|
|
"caileb/utils"
|
|
|
|
"github.com/andybalholm/brotli"
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/gofiber/fiber/v2/middleware/compress"
|
|
"github.com/gofiber/fiber/v2/middleware/logger"
|
|
"github.com/gofiber/template/html/v2"
|
|
)
|
|
|
|
// getEnvInt tries to read an integer from the environment.
|
|
// If it's missing or invalid, it returns the fallback.
|
|
func getEnvInt(key string, fallback int) int {
|
|
if value, exists := os.LookupEnv(key); exists {
|
|
if intVal, err := strconv.Atoi(value); err == nil {
|
|
return intVal
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// getEnvBool reads a boolean from the environment.
|
|
// Returns fallback if missing; accepts "true" or "1" as true.
|
|
func getEnvBool(key string, fallback bool) bool {
|
|
if value, exists := os.LookupEnv(key); exists {
|
|
return strings.ToLower(value) == "true" || value == "1"
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// validatePathParam rejects path params with unsafe characters (../, slashes, etc.).
|
|
func validatePathParam(paramName string) fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
param := c.Params(paramName)
|
|
|
|
// Clean and validate path
|
|
cleanedParam := filepath.Clean(param)
|
|
|
|
// Security checks
|
|
if cleanedParam != param || strings.Contains(param, "..") ||
|
|
strings.Contains(param, "/") || strings.Contains(param, "\\") {
|
|
return c.Status(fiber.StatusForbidden).SendString("Forbidden")
|
|
}
|
|
|
|
// Alphanumeric validation
|
|
validChars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-."
|
|
for _, char := range param {
|
|
if !strings.ContainsRune(validChars, char) {
|
|
return c.Status(fiber.StatusForbidden).SendString("Forbidden")
|
|
}
|
|
}
|
|
|
|
return c.Next()
|
|
}
|
|
}
|
|
|
|
// customCompression adds Brotli or gzip compression depending on client support.
|
|
func customCompression() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
// Check Accept-Encoding header
|
|
acceptEncoding := c.Get("Accept-Encoding")
|
|
|
|
// Set Vary header
|
|
c.Append("Vary", "Accept-Encoding")
|
|
|
|
// Process after the handler has been executed
|
|
if err := c.Next(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only compress if response is successful and has body
|
|
if c.Response().StatusCode() != 200 || len(c.Response().Body()) < 1024 {
|
|
return nil
|
|
}
|
|
|
|
// Check content type for compressibility
|
|
contentType := string(c.Response().Header.ContentType())
|
|
if !isCompressible(contentType) {
|
|
return nil
|
|
}
|
|
|
|
body := c.Response().Body()
|
|
|
|
// Apply compression based on client support
|
|
if strings.Contains(acceptEncoding, "br") {
|
|
// Brotli compression
|
|
var buf bytes.Buffer
|
|
writer := brotli.NewWriterLevel(&buf, 7)
|
|
|
|
if _, err := writer.Write(body); err != nil {
|
|
return nil // Skip compression on error
|
|
}
|
|
|
|
if err := writer.Close(); err != nil {
|
|
return nil // Skip compression on error
|
|
}
|
|
|
|
compressed := buf.Bytes()
|
|
|
|
// Only use compression if it's actually smaller
|
|
if len(compressed) < len(body) {
|
|
c.Response().Header.Set("Content-Encoding", "br")
|
|
c.Response().SetBodyRaw(compressed)
|
|
}
|
|
} else if strings.Contains(acceptEncoding, "gzip") {
|
|
// Let the built-in gzip middleware handle it
|
|
c.Response().Header.Set("Content-Encoding", "gzip")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// isCompressible returns true for common types that benefit from compression.
|
|
func isCompressible(contentType string) bool {
|
|
compressibleTypes := []string{
|
|
"text/", "application/json", "application/javascript",
|
|
"application/xml", "image/svg", "font/",
|
|
"application/wasm", "application/xhtml", "application/rss",
|
|
}
|
|
|
|
for _, t := range compressibleTypes {
|
|
if strings.Contains(contentType, t) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// main parses flags, sets up middleware/plugins, and starts the server.
|
|
// It also handles graceful shutdown signals.
|
|
func main() {
|
|
// Parse command line flags
|
|
prodMode := flag.Bool("p", false, "Run in production mode")
|
|
devMode := flag.Bool("d", false, "Run in development mode")
|
|
daemonMode := flag.Bool("b", false, "Run as a daemon (background process)")
|
|
port := flag.String("port", "1488", "Port to listen on")
|
|
skipPOW := flag.Bool("skip-pow", false, "Skip proof-of-work protection")
|
|
flag.Parse()
|
|
|
|
// Handle daemon mode - fork a new process and exit the parent
|
|
if *daemonMode && os.Getenv("_DAEMON_CHILD") != "1" {
|
|
// Prepare the command to run this program again as a child
|
|
cmd := exec.Command(os.Args[0], os.Args[1:]...)
|
|
cmd.Env = append(os.Environ(), "_DAEMON_CHILD=1")
|
|
cmd.Start()
|
|
|
|
log.Printf("Server started in daemon mode with PID: %d\n", cmd.Process.Pid)
|
|
// Write the PID to a file for later reference
|
|
pidFile := "server.pid"
|
|
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0644); err != nil {
|
|
log.Printf("Warning: Could not write PID file: %v\n", err)
|
|
}
|
|
|
|
// Exit the parent process
|
|
os.Exit(0)
|
|
}
|
|
|
|
// Set environment based on flags
|
|
if *prodMode {
|
|
os.Setenv("APP_ENV", "production")
|
|
} else if *devMode {
|
|
os.Setenv("APP_ENV", "development")
|
|
} else {
|
|
// Default to production mode if no mode is specified
|
|
os.Setenv("APP_ENV", "production")
|
|
}
|
|
|
|
// Configure minification options from environment variables
|
|
opts := utils.DefaultMinifierOptions()
|
|
opts.MaxWorkers = getEnvInt("MINIFY_WORKERS", opts.MaxWorkers)
|
|
opts.SkipUnchanged = getEnvBool("MINIFY_SKIP_UNCHANGED", opts.SkipUnchanged)
|
|
opts.RemoveComments = getEnvBool("MINIFY_REMOVE_COMMENTS", opts.RemoveComments)
|
|
opts.KeepConditionalComments = getEnvBool("MINIFY_KEEP_CONDITIONAL_COMMENTS", opts.KeepConditionalComments)
|
|
opts.KeepSpecialComments = getEnvBool("MINIFY_KEEP_SPECIAL_COMMENTS", opts.KeepSpecialComments)
|
|
|
|
// Minify assets from develop to public
|
|
log.Println("Minifying assets from /develop to /public directories...")
|
|
if err := utils.MinifyAssetsWithOptions(opts); err != nil {
|
|
log.Fatalf("Failed to minify assets: %v", err)
|
|
}
|
|
|
|
// Setup the template engine
|
|
engine := html.New("./public/static", ".html")
|
|
engine.Reload(os.Getenv("APP_ENV") != "production") // Enable reloading in development mode
|
|
|
|
// Create a new Fiber app with a custom error handler (serving error.html).
|
|
app := fiber.New(fiber.Config{
|
|
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
|
code := fiber.StatusInternalServerError
|
|
if e, ok := err.(*fiber.Error); ok {
|
|
code = e.Code
|
|
}
|
|
return c.Status(code).SendFile(filepath.Join("public", "html", "error.html"))
|
|
},
|
|
StrictRouting: true, // Enable strict routing for better path validation
|
|
Views: engine, // Set the template engine
|
|
ProxyHeader: "X-Forwarded-For", // Trust X-Forwarded-For header
|
|
EnableTrustedProxyCheck: true, // Enable proxy checking
|
|
TrustedProxies: []string{"127.0.0.1", "::1"}, // Add your NAS IP here
|
|
})
|
|
|
|
// Logger middleware only in development mode
|
|
if os.Getenv("APP_ENV") != "production" {
|
|
// API routes: log method, path and latency only (no status)
|
|
app.Use(logger.New(logger.Config{
|
|
Format: "${time} ${method} ${path} - ${latency}",
|
|
TimeFormat: "2006-01-02T15:04:05",
|
|
TimeZone: "Local",
|
|
Next: func(c *fiber.Ctx) bool {
|
|
// skip this logger for non-API paths
|
|
return !strings.HasPrefix(c.Path(), "/api")
|
|
},
|
|
}))
|
|
// Non-API routes: log full details including status
|
|
app.Use(logger.New(logger.Config{
|
|
Format: "${time} ${status} | ${method} ${path} - ${latency}",
|
|
TimeFormat: "2006-01-02T15:04:05",
|
|
TimeZone: "Local",
|
|
Next: func(c *fiber.Ctx) bool {
|
|
// skip this logger for API paths
|
|
return strings.HasPrefix(c.Path(), "/api")
|
|
},
|
|
}))
|
|
log.Printf("Logger middleware enabled (%s mode)\n", os.Getenv("APP_ENV"))
|
|
}
|
|
|
|
// Force text/html content type for HTML files for better compression detection
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
path := c.Path()
|
|
// Only set content type for HTML files
|
|
if strings.HasSuffix(path, ".html") || path == "/" || (len(path) > 0 && !strings.Contains(path, ".")) {
|
|
c.Set("Content-Type", "text/html; charset=utf-8")
|
|
} else if strings.HasSuffix(path, ".json") {
|
|
c.Set("Content-Type", "application/json")
|
|
}
|
|
return c.Next()
|
|
})
|
|
|
|
// Built-in compression for non-Brotli clients
|
|
app.Use(compress.New(compress.Config{
|
|
Level: 7,
|
|
Next: func(c *fiber.Ctx) bool {
|
|
// Skip if client accepts Brotli
|
|
return strings.Contains(c.Get("Accept-Encoding"), "br")
|
|
},
|
|
}))
|
|
|
|
// Custom Brotli compression for supported clients
|
|
app.Use(customCompression())
|
|
|
|
// Security headers middleware (improves site security)
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Set("X-Frame-Options", "SAMEORIGIN")
|
|
c.Set("X-Content-Type-Options", "nosniff")
|
|
c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
c.Set("X-Permitted-Cross-Domain-Policies", "none")
|
|
c.Set("Cross-Origin-Opener-Policy", "same-origin")
|
|
c.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
|
//c.Set("Content-Security-Policy", "base-uri 'self'; default-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' blob: *.caileb.com; img-src * data:; font-src 'self'; worker-src 'self' blob:; frame-src *.youtube.com *.youtube-nocookie.com *.bitchute.com *.rumble.com rumble.com; connect-src 'self' *.youtube.com *.youtube-nocookie.com *.ytimg.com *.bitchute.com *.rumble.com *.caileb.com;")
|
|
return c.Next()
|
|
})
|
|
|
|
// Serve static files from the public directory with optimized caching
|
|
// This should be before HTML POW middleware to correctly handle static files
|
|
app.Static("/", "./public", fiber.Static{
|
|
Compress: true, // Enable compression for static files
|
|
ByteRange: true, // Enable byte range requests
|
|
CacheDuration: 24 * time.Hour,
|
|
MaxAge: 86400,
|
|
})
|
|
|
|
// Special handler for favicon.ico to ensure it's properly served
|
|
app.Get("/favicon.ico", func(c *fiber.Ctx) error {
|
|
return c.SendFile("./public/favicon.ico", false)
|
|
})
|
|
|
|
// Load and apply registered middleware plugins
|
|
for _, handler := range middleware.LoadPlugins(*skipPOW) {
|
|
app.Use(handler)
|
|
}
|
|
log.Println("Loaded middleware plugins")
|
|
|
|
// API group with POW protection
|
|
api := app.Group("/api")
|
|
|
|
// Endpoint to verify POW solutions and issue tokens
|
|
api.Post("/pow/verify", middleware.VerifyCheckpointHandler)
|
|
|
|
// Challenge endpoint for secure POW parameters
|
|
api.Get("/pow/challenge", middleware.GetCheckpointChallengeHandler)
|
|
|
|
// Backwards compatibility for existing clients
|
|
api.Get("/verify", middleware.VerifyCheckpointHandler)
|
|
|
|
// Homepage route: serve index.html from public/html/ with compression
|
|
app.Get("/", func(c *fiber.Ctx) error {
|
|
c.Set("Content-Type", "text/html; charset=utf-8")
|
|
c.Response().Header.Add("Vary", "Accept-Encoding")
|
|
return c.SendFile(filepath.Join("public", "html", "index.html"))
|
|
})
|
|
|
|
// Dynamic page route using the validation middleware
|
|
app.Get("/:page", validatePathParam("page"), func(c *fiber.Ctx) error {
|
|
page := c.Params("page")
|
|
c.Set("Content-Type", "text/html; charset=utf-8")
|
|
c.Response().Header.Add("Vary", "Accept-Encoding")
|
|
return c.SendFile(filepath.Join("public", "html", page+".html"))
|
|
})
|
|
|
|
// Catch-all: serve a 404 error page for unmatched routes
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Set("Content-Type", "text/html; charset=utf-8")
|
|
c.Response().Header.Add("Vary", "Accept-Encoding")
|
|
return c.Status(404).SendFile(filepath.Join("public", "html", "error.html"))
|
|
})
|
|
|
|
// Start the server
|
|
go func() {
|
|
addr := ":" + *port
|
|
log.Printf("Server starting on %s in %s mode\n", addr, os.Getenv("APP_ENV"))
|
|
if err := app.Listen(addr); err != nil {
|
|
log.Fatalf("Server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Start the GeoIP database update routine
|
|
go startGeoIPUpdateRoutine()
|
|
|
|
// If running as daemon child, no need to wait for signals in foreground
|
|
if os.Getenv("_DAEMON_CHILD") == "1" {
|
|
// In daemon mode, we still need to wait for signals
|
|
// but we can close stdout/stderr
|
|
if f, err := os.OpenFile("/dev/null", os.O_RDWR, 0); err == nil {
|
|
// Redirect stdout/stderr to /dev/null for true daemon behavior
|
|
os.Stdout = f
|
|
os.Stderr = f
|
|
// Don't close f as it's now used by os.Stdout and os.Stderr
|
|
}
|
|
}
|
|
|
|
// Graceful shutdown handling
|
|
quit := make(chan os.Signal, 1)
|
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
<-quit
|
|
|
|
log.Println("Shutting down server...")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := app.ShutdownWithContext(ctx); err != nil {
|
|
log.Fatalf("Server forced to shutdown: %v", err)
|
|
}
|
|
|
|
// Close the token store database
|
|
if err := middleware.CloseTokenStore(); err != nil {
|
|
log.Printf("Error closing token store: %v", err)
|
|
}
|
|
|
|
log.Println("Server exiting")
|
|
}
|
|
|
|
// startGeoIPUpdateRoutine starts a goroutine that updates GeoIP databases daily
|
|
func startGeoIPUpdateRoutine() {
|
|
// Start immediately after server startup to ensure databases are fresh
|
|
updateGeoIPDatabases()
|
|
|
|
// Then schedule daily updates
|
|
ticker := time.NewTicker(24 * time.Hour)
|
|
go func() {
|
|
for range ticker.C {
|
|
updateGeoIPDatabases()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// updateGeoIPDatabases downloads the latest GeoLite2 Country and ASN databases
|
|
func updateGeoIPDatabases() {
|
|
// MaxMind account credentials
|
|
accountID := "1015174"
|
|
licenseKey := "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"
|
|
|
|
// Database paths and URLs
|
|
databases := []struct {
|
|
name string
|
|
url string
|
|
destFile string
|
|
}{
|
|
{
|
|
name: "GeoLite2-Country",
|
|
url: "https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz",
|
|
destFile: "./data/GeoLite2-Country.mmdb",
|
|
},
|
|
{
|
|
name: "GeoLite2-ASN",
|
|
url: "https://download.maxmind.com/geoip/databases/GeoLite2-ASN/download?suffix=tar.gz",
|
|
destFile: "./data/GeoLite2-ASN.mmdb",
|
|
},
|
|
}
|
|
|
|
// Ensure data directory exists
|
|
if err := os.MkdirAll("./data", 0755); err != nil {
|
|
log.Printf("ERROR: Failed to create data directory: %v", err)
|
|
return
|
|
}
|
|
|
|
// Create HTTP client that follows redirects
|
|
client := &http.Client{
|
|
Timeout: 5 * time.Minute,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
// MaxMind uses Cloudflare R2 for redirects, follow them
|
|
if len(via) >= 10 {
|
|
return fmt.Errorf("too many redirects")
|
|
}
|
|
// Add basic auth to the redirected request if needed
|
|
if req.URL.Host != "mm-prod-geoip-databases.a2649acb697e2c09b632799562c076f2.r2.cloudflarestorage.com" {
|
|
req.SetBasicAuth(accountID, licenseKey)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// Download and process each database
|
|
for _, db := range databases {
|
|
log.Printf("Checking for updates to %s...", db.name)
|
|
|
|
// First, check if an update is needed via HEAD request
|
|
headReq, err := http.NewRequest("HEAD", db.url, nil)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to create HEAD request for %s: %v", db.name, err)
|
|
continue
|
|
}
|
|
headReq.SetBasicAuth(accountID, licenseKey)
|
|
|
|
headResp, err := client.Do(headReq)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to make HEAD request for %s: %v", db.name, err)
|
|
continue
|
|
}
|
|
headResp.Body.Close()
|
|
|
|
// Check if file exists and get its modification time
|
|
updateNeeded := true
|
|
if fileInfo, err := os.Stat(db.destFile); err == nil {
|
|
lastModified := headResp.Header.Get("Last-Modified")
|
|
if lastModified != "" {
|
|
remoteTime, err := time.Parse(time.RFC1123, lastModified)
|
|
if err == nil {
|
|
// Only update if remote file is newer
|
|
if !remoteTime.After(fileInfo.ModTime()) {
|
|
log.Printf("No update needed for %s, local copy is current", db.name)
|
|
updateNeeded = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !updateNeeded {
|
|
continue
|
|
}
|
|
|
|
// Download the database
|
|
log.Printf("Downloading %s...", db.name)
|
|
req, err := http.NewRequest("GET", db.url, nil)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to create request for %s: %v", db.name, err)
|
|
continue
|
|
}
|
|
req.SetBasicAuth(accountID, licenseKey)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to download %s: %v", db.name, err)
|
|
continue
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Printf("ERROR: Failed to download %s: HTTP %d", db.name, resp.StatusCode)
|
|
continue
|
|
}
|
|
|
|
// Create a temporary file to store the downloaded archive
|
|
tempFile, err := os.CreateTemp("", "geoip-*.tar.gz")
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to create temp file for %s: %v", db.name, err)
|
|
continue
|
|
}
|
|
defer os.Remove(tempFile.Name())
|
|
|
|
// Copy the response body to the temporary file
|
|
_, err = io.Copy(tempFile, resp.Body)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to save downloaded %s: %v", db.name, err)
|
|
tempFile.Close()
|
|
continue
|
|
}
|
|
tempFile.Close()
|
|
|
|
// Extract the .mmdb file from the tar.gz archive
|
|
extracted, err := extractMMDBFromTarGz(tempFile.Name(), db.name)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to extract %s: %v", db.name, err)
|
|
continue
|
|
}
|
|
|
|
// Move the extracted file to the destination
|
|
err = os.Rename(extracted, db.destFile)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to move %s to destination: %v", db.name, err)
|
|
os.Remove(extracted) // Clean up
|
|
continue
|
|
}
|
|
|
|
log.Printf("Successfully updated %s", db.name)
|
|
}
|
|
|
|
// Reload the databases in the middleware
|
|
middleware.ReloadGeoIPDatabases()
|
|
}
|
|
|
|
// extractMMDBFromTarGz extracts the .mmdb file from a tar.gz archive
|
|
func extractMMDBFromTarGz(tarGzPath, dbName string) (string, error) {
|
|
file, err := os.Open(tarGzPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer file.Close()
|
|
|
|
gzr, err := gzip.NewReader(file)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer gzr.Close()
|
|
|
|
tr := tar.NewReader(gzr)
|
|
|
|
// Create a temporary directory for extraction
|
|
tempDir, err := os.MkdirTemp("", "geoip-extract-")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Find and extract the .mmdb file
|
|
var mmdbPath string
|
|
for {
|
|
header, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
os.RemoveAll(tempDir)
|
|
return "", err
|
|
}
|
|
|
|
// Look for the .mmdb file in the archive
|
|
if strings.HasSuffix(header.Name, ".mmdb") && strings.Contains(header.Name, dbName) {
|
|
// Extract to temporary directory
|
|
mmdbPath = filepath.Join(tempDir, filepath.Base(header.Name))
|
|
outFile, err := os.Create(mmdbPath)
|
|
if err != nil {
|
|
os.RemoveAll(tempDir)
|
|
return "", err
|
|
}
|
|
|
|
if _, err := io.Copy(outFile, tr); err != nil {
|
|
outFile.Close()
|
|
os.RemoveAll(tempDir)
|
|
return "", err
|
|
}
|
|
outFile.Close()
|
|
break
|
|
}
|
|
}
|
|
|
|
if mmdbPath == "" {
|
|
os.RemoveAll(tempDir)
|
|
return "", fmt.Errorf("no .mmdb file found in archive for %s", dbName)
|
|
}
|
|
|
|
return mmdbPath, nil
|
|
}
|