Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 /
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
185 changes: 173 additions & 12 deletions main.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand All @@ -38,37 +48,79 @@ type ApiResponse struct {
}

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(dbPath)
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)
}

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) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}

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:]
}
Expand All @@ -80,7 +132,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]
Expand All @@ -94,7 +173,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)
Expand All @@ -108,7 +186,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)
Expand All @@ -126,6 +203,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)

Expand Down