From 6d6c8f2319db0a16c5e4b820aa92f8562cf878e6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 07:50:19 +0000 Subject: [PATCH 1/3] feat: Add visit counter with geolocation This commit introduces a feature to track visits for each redirected domain, including geolocation information based on the visitor's IP address. A SQLite database (`stats.db`) is used to persistently store visit data. Each visit records the URL, a timestamp, and the visitor's country and city. The geolocation is performed by calling the `ip-api.com` external service. While a local database like GeoLite2 was initially considered, the external API is simpler to integrate for this project's scale. A new API endpoint `/stats` is added to expose the collected data. It returns a JSON response with the total number of visits, a breakdown by URL, and for each URL, a breakdown by country. It also includes a simple metric for "unique users today," which is based on a count of distinct cities. The previous in-memory visit counter has been removed. A `.gitignore` file has also been added to exclude build artifacts and the database file from version control. --- .gitignore | 17 ++++++ go.mod | 2 + go.sum | 2 + main.go | 175 +++++++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 184 insertions(+), 12 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6c3203 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Binaries +trips-redirect + +# Database +stats.db + +# Logs +server.log + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db diff --git a/go.mod b/go.mod index fab642b..1880f5c 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module trips-redirect go 1.25.0 require gopkg.in/yaml.v3 v3.0.1 + +require github.com/mattn/go-sqlite3 v1.14.32 // indirect diff --git a/go.sum b/go.sum index a62c313..e7adfed 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go index 48da39a..edbc6a3 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,19 @@ package main import ( + "database/sql" "encoding/json" "fmt" "gopkg.in/yaml.v3" "log" + "net" "net/http" "os" "sort" "sync" "time" + + _ "github.com/mattn/go-sqlite3" ) const API_URL = "https://api.polarsteps.com" @@ -19,15 +23,21 @@ var cache = struct { store map[string]string }{store: make(map[string]string)} + type Config struct { Domains map[string]string `yaml:"domains"` } type Trip struct { - ID int `json:"id"` - Slug string `json:"slug"` - StartDate int64 `json:"start_date"` - EndDate *int64 `json:"end_date"` + ID int `json:"id"` + Slug string `json:"slug"` + StartDate int64 `json:"start_date"` + EndDate *int64 `json:"end_date"` +} + +type GeoLocation struct { + Country string `json:"country"` + City string `json:"city"` } // Structure flexible pour gérer différents formats de réponse API @@ -38,13 +48,21 @@ type ApiResponse struct { } var cfg Config +var db *sql.DB func main() { + var err error + db, err = initDB("stats.db") + if err != nil { + log.Fatal("❌ Cannot initialize database:", err) + } + defer db.Close() + yamlFile, err := os.ReadFile("domains.yaml") if err != nil { log.Fatal("❌ Cannot read domains.yaml:", err) } - + if err := yaml.Unmarshal(yamlFile, &cfg); err != nil { log.Fatal("❌ Cannot parse domains.yaml:", err) } @@ -52,23 +70,47 @@ func main() { go startCacheResetter() http.HandleFunc("/", handler) - + http.HandleFunc("/stats", statsHandler) + port := os.Getenv("PORT") if port == "" { port = "3000" } - + log.Printf("🚀 Redirector running on :%s\n", port) log.Fatal(http.ListenAndServe(":"+port, nil)) } +func initDB(filepath string) (*sql.DB, error) { + db, err := sql.Open("sqlite3", filepath) + if err != nil { + return nil, err + } + + createTableSQL := ` + CREATE TABLE IF NOT EXISTS visits ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "url" TEXT, + "timestamp" DATETIME, + "country" TEXT, + "city" TEXT + );` + + _, err = db.Exec(createTableSQL) + if err != nil { + return nil, err + } + + log.Println("✅ Database initialized and table created.") + return db, nil +} + func handler(w http.ResponseWriter, r *http.Request) { host := r.Header.Get("X-Forwarded-Host") if host == "" { host = r.Host } - - // Supprimer le préfixe www. si présent + if len(host) > 4 && host[:4] == "www." { host = host[4:] } @@ -80,7 +122,34 @@ func handler(w http.ResponseWriter, r *http.Request) { return } - log.Printf("🌍 Request from host=%s → username=%s", host, username) + ip := r.Header.Get("X-Forwarded-For") + if ip == "" { + ip = r.RemoteAddr + } + if realIP, _, err := net.SplitHostPort(ip); err == nil { + ip = realIP + } + + geo, err := getGeoLocation(ip) + if err != nil { + log.Printf("⚠️ Could not get geolocation for IP %s: %v", ip, err) + } + + go func() { + country, city := "unknown", "unknown" + if geo != nil { + country = geo.Country + city = geo.City + log.Printf("🌍 Request from host=%s → username=%s, location=%s, %s", host, username, city, country) + } else { + log.Printf("🌍 Request from host=%s → username=%s", host, username) + } + + _, err := db.Exec("INSERT INTO visits (url, timestamp, country, city) VALUES (?, ?, ?, ?)", host, time.Now(), country, city) + if err != nil { + log.Printf("⚠️ Failed to record visit for %s: %v", host, err) + } + }() cache.RLock() cachedURL, found := cache.store[host] @@ -94,7 +163,6 @@ func handler(w http.ResponseWriter, r *http.Request) { log.Printf("❌ Cache miss for %s", host) - // Récupérer les voyages de l'utilisateur trips, err := fetchUserTrips(username) if err != nil { log.Printf("⚠️ Failed to fetch trips for %s: %v", username, err) @@ -108,7 +176,6 @@ func handler(w http.ResponseWriter, r *http.Request) { return } - // Sélectionner le voyage approprié selectedTrip := selectTrip(trips) if selectedTrip == nil { log.Printf("↩️ No suitable trip found for %s → redirect to profile", username) @@ -126,6 +193,90 @@ func handler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target, http.StatusFound) } +func getGeoLocation(ip string) (*GeoLocation, error) { + if ip == "" || ip == "::1" || ip == "127.0.0.1" { + return &GeoLocation{Country: "local", City: "localhost"}, nil + } + + resp, err := http.Get("http://ip-api.com/json/" + ip) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var geo GeoLocation + if err := json.NewDecoder(resp.Body).Decode(&geo); err != nil { + return nil, err + } + return &geo, nil +} + +func statsHandler(w http.ResponseWriter, r *http.Request) { + type CountryStats struct { + Total int `json:"total"` + Details map[string]int `json:"countries"` + } + + type StatsResponse struct { + TotalVisits int `json:"total_visits"` + VisitsByUrl map[string]CountryStats `json:"visits_by_url"` + UniqueUsers int `json:"unique_users_today"` + } + + rows, err := db.Query("SELECT url, country FROM visits") + if err != nil { + http.Error(w, "Failed to query stats", http.StatusInternalServerError) + return + } + defer rows.Close() + + stats := make(map[string]map[string]int) + totalVisits := 0 + for rows.Next() { + var url, country string + if err := rows.Scan(&url, &country); err != nil { + log.Printf("⚠️ Error scanning row: %v", err) + continue + } + totalVisits++ + if _, ok := stats[url]; !ok { + stats[url] = make(map[string]int) + } + stats[url][country]++ + } + + visitsByUrl := make(map[string]CountryStats) + for url, countryCounts := range stats { + total := 0 + for _, count := range countryCounts { + total += count + } + visitsByUrl[url] = CountryStats{ + Total: total, + Details: countryCounts, + } + } + + // Compter les visiteurs uniques pour la journée en cours + var uniqueUsersToday int + err = db.QueryRow("SELECT COUNT(DISTINCT city) FROM visits WHERE date(timestamp) = date('now')").Scan(&uniqueUsersToday) + if err != nil { + log.Printf("⚠️ Failed to query unique users: %v", err) + } + + response := StatsResponse{ + TotalVisits: totalVisits, + VisitsByUrl: visitsByUrl, + UniqueUsers: uniqueUsersToday, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Error encoding stats: %v", err) + http.Error(w, "Error encoding stats", http.StatusInternalServerError) + } +} + func fetchUserTrips(username string) ([]Trip, error) { url := fmt.Sprintf("%s/users/byusername/%s", API_URL, username) From 977bdfab791118c2f9d6884dbd40b33e1aa9121b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 07:54:56 +0000 Subject: [PATCH 2/3] fix: Enable cgo in Dockerfile for sqlite support This commit fixes the Docker build by enabling cgo, which is required by the `go-sqlite3` driver. The `golang:alpine` base image does not have cgo enabled by default. This change adds the necessary build tools (`gcc`, `musl-dev`) to the builder stage and sets `CGO_ENABLED=1` during the `go build` command. This ensures that the application can correctly link against the C-based SQLite library when built inside the Docker container. --- Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index c828e04..e33be7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,13 @@ FROM golang:1.25-alpine AS builder WORKDIR /app +# Install C build tools needed for CGO +RUN apk add --no-cache gcc musl-dev COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN go build -o redirector . - +# Build with CGO enabled +RUN CGO_ENABLED=1 go build -o redirector . + FROM alpine:3.18 RUN apk add --no-cache ca-certificates WORKDIR / From f730e80899fc6358cfc34e424a9c56c9be9404e2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 08:05:14 +0000 Subject: [PATCH 3/3] fix: Resolve visit counter bug and add persistence docs This commit addresses two issues: 1. The visit counter was incorrectly incrementing for any visited path. The main handler has been modified to only process requests for the root path (`/`), ensuring that only relevant visits are counted. 2. The database was not persistent when running in Docker. The application now uses the `DB_PATH` environment variable to allow specifying a custom path for the database file. The `README.md` has been updated with instructions on how to use this with Docker volumes to achieve data persistence. --- README.md | 18 ++++++++++++++++++ main.go | 12 +++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d84b23..35ba335 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,24 @@ The service will automatically handle `www.` subdomains. For example, if you con This will start the service on port 3000. +### Data Persistence + +The visit statistics are stored in a SQLite database file. To ensure that your statistics are not lost when you update or restart the Docker container, you should store the database file on your host machine using a Docker volume. + +You can specify the path for the database file inside the container by using the `DB_PATH` environment variable. + +Here is an example of how to run the service with a persistent database stored in `/path/to/data/stats.db` on your host machine: + +```bash +docker run -d \ + -p 3000:3000 \ + -v $(pwd)/domains.yaml:/domains.yaml \ + -v /path/to/data:/data \ + -e DB_PATH="/data/stats.db" \ + --name trips-redirect \ + trips-redirect +``` + ## Contributing Contributions are welcome! If you have any ideas, suggestions, or bug reports, please open an issue or submit a pull request. diff --git a/main.go b/main.go index edbc6a3..9e3aec9 100644 --- a/main.go +++ b/main.go @@ -51,8 +51,13 @@ var cfg Config var db *sql.DB func main() { + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "stats.db" + } + var err error - db, err = initDB("stats.db") + db, err = initDB(dbPath) if err != nil { log.Fatal("❌ Cannot initialize database:", err) } @@ -106,6 +111,11 @@ func initDB(filepath string) (*sql.DB, error) { } func handler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + host := r.Header.Get("X-Forwarded-Host") if host == "" { host = r.Host