package main import ( "database/sql" "encoding/json" "fmt" "log" "net/http" "os" "strings" "time" _ "modernc.org/sqlite" ) type FeedbackRequest struct { Ext string `json:"ext"` Reason string `json:"reason"` Text string `json:"text"` } type FeedbackResponse struct { OK bool `json:"ok"` } func main() { dbPath := os.Getenv("GS_DB_PATH") if dbPath == "" { dbPath = "/var/lib/gs-feedback/feedback.db" } discordWebhook := os.Getenv("GS_DISCORD_WEBHOOK") port := os.Getenv("GS_PORT") if port == "" { port = "8080" } // Ensure directory exists if err := os.MkdirAll(strings.TrimSuffix(dbPath, "/feedback.db"), 0755); err != nil { log.Printf("warn: could not create db directory: %v", err) } db, err := sql.Open("sqlite", dbPath) if err != nil { log.Fatalf("failed to open database: %v", err) } defer db.Close() // Create tables if not exists schema := ` CREATE TABLE IF NOT EXISTS feedback ( id INTEGER PRIMARY KEY AUTOINCREMENT, ext TEXT NOT NULL, reason TEXT DEFAULT '', text TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS ratings ( id INTEGER PRIMARY KEY AUTOINCREMENT, ext TEXT NOT NULL, rating INTEGER NOT NULL CHECK(rating >= 1 AND rating <= 5), ip_hash TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_ratings_ext ON ratings(ext); CREATE INDEX IF NOT EXISTS idx_ratings_ip ON ratings(ip_hash); ` if _, err := db.Exec(schema); err != nil { log.Fatalf("failed to create tables: %v", err) } mux := http.NewServeMux() // POST /feedback — receive feedback mux.HandleFunc("/feedback", func(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.WriteHeader(http.StatusOK) return } if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // CORS w.Header().Set("Access-Control-Allow-Origin", "*") var req FeedbackRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } // Validate req.Ext = strings.TrimSpace(req.Ext) req.Reason = strings.TrimSpace(req.Reason) req.Text = strings.TrimSpace(req.Text) if len(req.Text) > 500 { http.Error(w, "Text too long", http.StatusBadRequest) return } // Insert into SQLite _, err := db.Exec( "INSERT INTO feedback (ext, reason, text) VALUES (?, ?, ?)", req.Ext, req.Reason, req.Text, ) if err != nil { log.Printf("error inserting feedback: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } log.Printf("feedback received: ext=%s reason=%s", req.Ext, req.Reason) // Discord webhook (best-effort, non-blocking) if discordWebhook != "" { go sendDiscordNotification(discordWebhook, req) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(FeedbackResponse{OK: true}) }) // GET /feedback/stats — simple stats endpoint (optional, for future dashboard) mux.HandleFunc("/feedback/stats", func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } rows, err := db.Query(` SELECT ext, reason, COUNT(*) as count FROM feedback GROUP BY ext, reason ORDER BY count DESC `) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } defer rows.Close() type Stat struct { Ext string `json:"ext"` Reason string `json:"reason"` Count int `json:"count"` } var stats []Stat for rows.Next() { var s Stat if err := rows.Scan(&s.Ext, &s.Reason, &s.Count); err != nil { continue } stats = append(stats, s) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stats) }) // POST /rate — submit a star rating (1-5) for an extension mux.HandleFunc("/rate", func(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.WriteHeader(http.StatusOK) return } if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Access-Control-Allow-Origin", "*") var req struct { Ext string `json:"ext"` Rating int `json:"rating"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } req.Ext = strings.TrimSpace(req.Ext) if req.Ext == "" { http.Error(w, "ext is required", http.StatusBadRequest) return } if req.Rating < 1 || req.Rating > 5 { http.Error(w, "rating must be 1-5", http.StatusBadRequest) return } // Hash the IP for dedup (one vote per IP per extension) ip := r.RemoteAddr if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { ip = strings.Split(forwarded, ",")[0] } ipHash := fmt.Sprintf("%x", ip)[:16] // simple hash, enough for dedup // Check if already voted var existing int err := db.QueryRow("SELECT rating FROM ratings WHERE ext = ? AND ip_hash = ?", req.Ext, ipHash).Scan(&existing) if err == nil { // Update existing vote _, err = db.Exec("UPDATE ratings SET rating = ?, created_at = CURRENT_TIMESTAMP WHERE ext = ? AND ip_hash = ?", req.Rating, req.Ext, ipHash) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } } else { // Insert new vote _, err = db.Exec("INSERT INTO ratings (ext, rating, ip_hash) VALUES (?, ?, ?)", req.Ext, req.Rating, ipHash) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } } log.Printf("rating received: ext=%s rating=%d", req.Ext, req.Rating) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) }) // GET /ratings?ext= — get aggregate rating for an extension mux.HandleFunc("/ratings", func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Content-Type", "application/json") ext := strings.TrimSpace(r.URL.Query().Get("ext")) if ext == "" { // Return all ratings rows, err := db.Query(` SELECT ext, AVG(rating) as avg, COUNT(*) as count FROM ratings GROUP BY ext `) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } defer rows.Close() type RatingSummary struct { Ext string `json:"ext"` Average float64 `json:"average"` Count int `json:"count"` } var all []RatingSummary for rows.Next() { var s RatingSummary if err := rows.Scan(&s.Ext, &s.Average, &s.Count); err != nil { continue } all = append(all, s) } json.NewEncoder(w).Encode(all) return } // Single extension var avg float64 var count int err := db.QueryRow("SELECT COALESCE(AVG(rating), 0), COUNT(*) FROM ratings WHERE ext = ?", ext).Scan(&avg, &count) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]interface{}{ "ext": ext, "average": avg, "count": count, }) }) // Health check mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, "ok") }) log.Printf("gs-feedback starting on :%s (db=%s)", port, dbPath) srv := &http.Server{ Addr: ":" + port, Handler: mux, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } if err := srv.ListenAndServe(); err != nil { log.Fatalf("server error: %v", err) } } func sendDiscordNotification(webhookURL string, req FeedbackRequest) { reasonLabels := map[string]string{ "love-it": "Love it!", "works-fine": "Works as expected", "not-needed": "Don't need it", "too-many": "Too many extensions", "bugs": "Bugs or issues", "alternative": "Prefer an alternative", "other": "Other", } reasonLabel := reasonLabels[req.Reason] if reasonLabel == "" { reasonLabel = req.Reason } extLabel := req.Ext if extLabel == "" { extLabel = "unknown" } textPreview := req.Text if len(textPreview) > 200 { textPreview = textPreview[:200] + "..." } description := fmt.Sprintf("**Extension:** %s\n**Reason:** %s", extLabel, reasonLabel) if textPreview != "" { description += fmt.Sprintf("\n**Comment:** %s", textPreview) } payload := map[string]interface{}{ "embeds": []map[string]interface{}{ { "title": "📋 Feedback", "description": description, "color": 0xf97316, "timestamp": time.Now().UTC().Format(time.RFC3339), }, }, } body, _ := json.Marshal(payload) resp, err := http.Post(webhookURL, "application/json", strings.NewReader(string(body))) if err != nil { log.Printf("discord webhook error: %v", err) return } resp.Body.Close() }