Files
greasysprocket-website/feedback-svc/main.go
T
2026-07-10 11:11:05 +02:00

223 lines
5.3 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 table 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
);`
if _, err := db.Exec(schema); err != nil {
log.Fatalf("failed to create table: %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)
})
// 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()
}