Files
greasysprocket-website/feedback-svc/main.go
T
Jose Juan Moñino 903e51cf5b refactor: unify /coffee → /support
- Rename coffee.astro → support.astro (broader scope: ko-fi, crypto, wallets)
- Update all /coffee links → /support across website + extension pages
- Update nav, footer, hero, and all 5 extension .md files
- TabFM coffee banner already points to /support
2026-07-10 19:13:20 +02:00

357 lines
9.2 KiB
Go

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=<slug> — 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()
}