feat: initial Greasy Sprocket website — Astro static + Go feedback service
- Astro 5.x static site with content collections for extensions - Pages: index, [slug] (per-extension), coffee (Ko-fi), feedback form - Go binary gs-feedback: SQLite storage + Discord webhook notification - Nginx config: static serving + POST proxy to Go backend - Deploy script: build + rsync to VPS - ThockBoard landing page with features and sound pack listing
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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{
|
||||
"not-needed": "Didn'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": "📋 Uninstall 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()
|
||||
}
|
||||
Reference in New Issue
Block a user