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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,7 @@ server
.env
*.log

menta-account.txt
menta-account.txt
# EthicalMetrics local environment
.env.ethicalmetrics.local
ethicalmetrics/.env
35 changes: 35 additions & 0 deletions database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,38 @@ CREATE POLICY "Allow anonymous insert comments" ON comments
CREATE POLICY "Allow public read comments" ON comments
FOR SELECT
USING (true);

-- EthicalMetrics pageviews (aggregated by minute, privacy-preserving)
CREATE TABLE IF NOT EXISTS ethicalmetrics_pageviews (
site_key TEXT NOT NULL,
bucket TEXT NOT NULL,
pageviews BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (site_key, bucket)
);

CREATE INDEX IF NOT EXISTS idx_ethicalmetrics_pageviews_site_key ON ethicalmetrics_pageviews(site_key);

ALTER TABLE ethicalmetrics_pageviews ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Allow anonymous upsert pageviews" ON ethicalmetrics_pageviews
FOR ALL
USING (true)
WITH CHECK (true);

-- Atomic increment function for EthicalMetrics pageviews
DROP FUNCTION IF EXISTS increment_pageviews(text, text);
CREATE FUNCTION increment_pageviews(p_sitekey text, p_bucket text)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
INSERT INTO ethicalmetrics_pageviews (site_key, bucket, pageviews)
VALUES (p_sitekey, p_bucket, 1)
ON CONFLICT (site_key, bucket)
DO UPDATE SET pageviews = ethicalmetrics_pageviews.pageviews + 1;
END;
$$;

GRANT EXECUTE ON FUNCTION increment_pageviews(text, text) TO anon, authenticated, service_role;
18 changes: 18 additions & 0 deletions ethicalmetrics/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
71 changes: 71 additions & 0 deletions internal/database/supabase.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
Expand All @@ -18,6 +20,75 @@ type SupabaseClient struct {
httpClient *http.Client
}

type EthicalPageviewRecord struct {
SiteKey string `json:"site_key"`
Bucket string `json:"bucket"`
Pageviews int64 `json:"pageviews"`
}

func (c *SupabaseClient) UpsertEthicalPageview(ctx context.Context, siteKey, bucket string) error {
params := map[string]string{"p_sitekey": siteKey, "p_bucket": bucket}
return c.postJSON(ctx, "/rest/v1/rpc/increment_pageviews", params, "")
}

func (c *SupabaseClient) GetEthicalPageviews(ctx context.Context, siteKey string) (map[string]int64, error) {
var rows []EthicalPageviewRecord
if err := c.getJSON(ctx, fmt.Sprintf("/rest/v1/ethicalmetrics_pageviews?site_key=eq.%s&select=bucket,pageviews", url.QueryEscape(siteKey)), &rows); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit the metrics query to recent buckets

This query fetches every persisted minute bucket for a site, while the homepage chart later only displays the last 36 entries. Once the site has accumulated weeks or months of minute-level rows, each metrics request will scan, allocate, and return the full history before the browser discards almost all of it; add ordering/range filtering or a server-side limit for the recent window.

Useful? React with 👍 / 👎.

return nil, err
}
result := make(map[string]int64, len(rows))
for _, row := range rows {
result[row.Bucket] += row.Pageviews
}
return result, nil
}

func (c *SupabaseClient) postJSON(ctx context.Context, path string, payload interface{}, prefer string) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("apikey", c.key)
req.Header.Set("Authorization", "Bearer "+c.key)
req.Header.Set("Content-Type", "application/json")
if prefer != "" {
req.Header.Set("Prefer", prefer)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("database request failed: status %d, body: %s", resp.StatusCode, string(respBody))
}
return nil
}

func (c *SupabaseClient) getJSON(ctx context.Context, path string, target interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.URL+path, nil)
if err != nil {
return err
}
req.Header.Set("apikey", c.key)
req.Header.Set("Authorization", "Bearer "+c.key)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("database request failed: status %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(target)
}

func (c *SupabaseClient) HasReportFromIP(ctx context.Context, hash, ip string) (bool, error) {
if ip == "" {
return false, nil
Expand Down
146 changes: 146 additions & 0 deletions internal/http/ethicalmetrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package http

import (
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"

"github.com/gin-gonic/gin"
"github.com/livrasand/gitGost/internal/utils"
)

type ethicalPageview struct {
SiteKey string `json:"site_key" binding:"required"`
Path string `json:"path"`
Referrer string `json:"referrer"`
Browser string `json:"browser"`
OS string `json:"os"`
Device string `json:"device"`
}

type ethicalBucket struct {
Pageviews int64 `json:"pageviews"`
}

type ethicalMetricsStore struct {
mu sync.RWMutex
buckets map[string]map[string]int64
}

var ethicalStore = ethicalMetricsStore{buckets: make(map[string]map[string]int64)}

func EthicalMetricsPageviewHandler(c *gin.Context) {
if c.GetHeader("DNT") == "1" {
c.Status(http.StatusNoContent)
return
}
var input ethicalPageview
if err := c.ShouldBindJSON(&input); err != nil || strings.TrimSpace(input.SiteKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "site_key is required"})
return
}

path := normalizeEthicalPath(input.Path)
browser := normalizeEthicalCategory(input.Browser, "Chrome", "Firefox", "Safari", "Edge")
osName := normalizeEthicalCategory(input.OS, "Windows", "macOS", "Linux", "Android", "iOS")
device := normalizeEthicalCategory(input.Device, "Desktop", "Mobile", "Tablet")
country := ethicalCountry(c.Request)
bucket := time.Now().UTC().Truncate(time.Minute).Format(time.RFC3339)

key := strings.Join([]string{bucket, "page", path, browser, osName, device, country}, "|")
ethicalStore.mu.Lock()
if ethicalStore.buckets[input.SiteKey] == nil {
ethicalStore.buckets[input.SiteKey] = make(map[string]int64)
}
ethicalStore.buckets[input.SiteKey][key]++

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cap the in-memory pageview cache

Every accepted pageview is inserted into the process-wide ethicalStore, even when Supabase is configured, and the key includes caller-controlled site_key/path plus a new minute bucket. On a long-running deployment or from clients sending many distinct paths/site keys, this map grows without eviction and can steadily consume memory; consider bounding/pruning it or only using it when persistence is unavailable.

Useful? React with 👍 / 👎.

ethicalStore.mu.Unlock()
if dbClient != nil {
if err := dbClient.UpsertEthicalPageview(c.Request.Context(), input.SiteKey, bucket); err != nil {
utils.Log("Error upserting pageview for %s/%s: %v", input.SiteKey, bucket, err)
}
}
c.Status(http.StatusAccepted)
}

func EthicalMetricsPrivacyHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"version": "1.0", "last_updated": "2026-07-27",
"stores_ip": false, "stores_user_agent": false, "stores_cookie": false,
"stores_localstorage": false, "stores_session": false, "stores_fingerprint": false,
"stores_query_strings": false, "stores_events": false,
"transient_processing": gin.H{"ip": "used only for country lookup, then discarded"},
})
}

func EthicalMetricsManifestHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"product": "EthicalMetrics", "version": "0.1.0", "protocol": "1", "api": "v1", "measurement": "pageviews", "tracking": false, "storage": "aggregated", "privacy": "/privacy"})
}

func EthicalMetricsVersionHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"product": "EthicalMetrics", "version": "0.1.0", "protocol": "1"})
}

func EthicalMetricsMetricsHandler(c *gin.Context) {
site := strings.TrimPrefix(c.Param("site"), "")
if site == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "site is required"})
return
}
result := make(map[string]ethicalBucket)
if dbClient != nil {
if persisted, err := dbClient.GetEthicalPageviews(c.Request.Context(), site); err == nil {
for bucket, count := range persisted {
result[bucket] = ethicalBucket{Pageviews: count}
}
} else {
utils.Log("Error loading pageviews for %s: %v", site, err)
}
}
if len(result) == 0 {
// Fallback a memoria si no hay DB o falló la lectura
ethicalStore.mu.RLock()
defer ethicalStore.mu.RUnlock()
for key, count := range ethicalStore.buckets[site] {
parts := strings.SplitN(key, "|", 2)
if len(parts) == 2 {
result[parts[0]] = ethicalBucket{Pageviews: result[parts[0]].Pageviews + count}
}
}
}
c.JSON(http.StatusOK, gin.H{"site": site, "measurement": "pageviews", "buckets": result})
}

func normalizeEthicalPath(value string) string {
parsed, err := url.Parse(value)
if err == nil && parsed.Path != "" {
value = parsed.Path
}
if value == "" || !strings.HasPrefix(value, "/") {
return "/" + strings.TrimPrefix(value, "/")
}
return value
}

func normalizeEthicalCategory(value string, allowed ...string) string {
for _, candidate := range allowed {
if value == candidate {
return value
}
}
return "Other"
}

func ethicalCountry(r *http.Request) string {
ip := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ip = strings.TrimSpace(strings.Split(forwarded, ",")[0])
}
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
_ = ip
return "Unknown"
}
28 changes: 28 additions & 0 deletions internal/http/ethicalmetrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package http

import (
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
)

func TestEthicalMetricsNormalizesCategories(t *testing.T) {
if got := normalizeEthicalCategory("Chrome 142", "Chrome", "Firefox"); got != "Other" {
t.Fatalf("browser = %q", got)
}
if got := normalizeEthicalCategory("MacBookPro18,4", "Desktop", "Mobile", "Tablet"); got != "Other" {
t.Fatalf("device = %q", got)
}
}

func TestEthicalMetricsDNT(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("POST", "/v1/pageviews", nil)
ctx.Request.Header.Set("DNT", "1")
EthicalMetricsPageviewHandler(ctx)
if ctx.Writer.Status() != 204 {
t.Fatalf("status = %d", ctx.Writer.Status())
}
}
8 changes: 8 additions & 0 deletions internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ func SetupRouter(cfg *config.Config) *gin.Engine {
r.GET("/VERIFY", VerifyHandler)
r.GET("/gitgost-bin", BinaryHandler)

// EthicalMetrics embebido: mide pageviews agregados, nunca personas.
r.POST("/v1/pageviews", EthicalMetricsPageviewHandler)
r.GET("/v1/sites/:site/metrics", EthicalMetricsMetricsHandler)
r.GET("/privacy", EthicalMetricsPrivacyHandler)
r.GET("/manifest", EthicalMetricsManifestHandler)
r.GET("/version", EthicalMetricsVersionHandler)

// Badges
r.GET("/badges/:badge", BadgeHandler)
r.GET("/badge/:owner/:repo", BadgePRCountHandler)
Expand All @@ -248,6 +255,7 @@ func SetupRouter(cfg *config.Config) *gin.Engine {

// Static assets
r.Static("/assets", "./web/assets")
r.StaticFile("/ethicalmetrics.js", "./web/ethicalmetrics.js")

// API routes - ANONIMAS para git operations
v1 := r.Group("/v1")
Expand Down
9 changes: 9 additions & 0 deletions web/assets/images/surich.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions web/ethicalmetrics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
(function () {
const script = document.currentScript;
if (!script || !script.dataset.siteKey || navigator.doNotTrack === "1" || window.doNotTrack === "1") return;
const agent = navigator.userAgent || "";
const device = /Tablet|iPad/i.test(agent) ? "Tablet" : /Mobi|Android/i.test(agent) ? "Mobile" : "Desktop";
const browser = /Edg\//i.test(agent) ? "Edge" : /Firefox\//i.test(agent) ? "Firefox" : /Chrome\//i.test(agent) ? "Chrome" : /Safari\//i.test(agent) ? "Safari" : "Other";
const os = /Windows/i.test(agent) ? "Windows" : /Android/i.test(agent) ? "Android" : /iPhone|iPad|iPod/i.test(agent) ? "iOS" : /Mac OS X|Macintosh/i.test(agent) ? "macOS" : /Linux/i.test(agent) ? "Linux" : "Other";
const body = JSON.stringify({ site_key: script.dataset.siteKey, path: window.location.pathname || "/", referrer: document.referrer || "", browser, os, device });
fetch(script.dataset.endpoint || "/v1/pageviews", { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true }).catch(() => {});
})();
Loading