Files
Horchposten/handlers/admin.go
2026-03-08 14:44:50 +00:00

591 lines
18 KiB
Go

package handlers
import (
"crypto/subtle"
"database/sql"
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"github.com/labstack/echo/v4"
)
// AdminAuth returns middleware that validates the API key from either
// the X-API-Key header or the ?key= query parameter, making the admin
// UI accessible from a browser.
func AdminAuth(key string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
provided := c.Request().Header.Get("X-API-Key")
if provided == "" {
provided = c.QueryParam("key")
}
if provided == "" {
return c.HTML(http.StatusUnauthorized, unauthorizedHTML)
}
if subtle.ConstantTimeCompare([]byte(provided), []byte(key)) != 1 {
return c.HTML(http.StatusUnauthorized, unauthorizedHTML)
}
// Store key so templates can propagate it in links.
c.Set("api_key", provided)
return next(c)
}
}
}
const unauthorizedHTML = `<!DOCTYPE html>
<html><head><title>Unauthorized</title>` + cssStyle + `</head>
<body>
<div class="container" style="margin-top:80px;text-align:center">
<h1>Unauthorized</h1>
<p style="margin-top:12px;color:#666">Provide your API key as a <code>?key=</code> query parameter.</p>
</div>
</body></html>`
// knownTables is the ordered list of tables exposed in the admin UI.
var knownTables = []string{
"players",
"player_ips",
"game_sessions",
"device_infos",
"high_scores",
}
type columnInfo struct {
Name string
PK bool
}
func getColumns(db *sql.DB, table string) ([]columnInfo, error) {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return nil, err
}
defer rows.Close()
var cols []columnInfo
for rows.Next() {
var cid int
var name, typ string
var notNull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil {
return nil, err
}
cols = append(cols, columnInfo{Name: name, PK: pk == 1})
}
return cols, rows.Err()
}
func getPKColumn(cols []columnInfo) string {
for _, c := range cols {
if c.PK {
return c.Name
}
}
return ""
}
func isKnownTable(name string) bool {
for _, t := range knownTables {
if t == name {
return true
}
}
return false
}
// AdminDashboard handles GET /admin/ — lists all tables with row counts.
func AdminDashboard(db *sql.DB) echo.HandlerFunc {
return func(c echo.Context) error {
type tableInfo struct {
Name string
Count int
}
var tables []tableInfo
for _, t := range knownTables {
var count int
row := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", t))
if err := row.Scan(&count); err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
}
tables = append(tables, tableInfo{Name: t, Count: count})
}
data := map[string]interface{}{
"Tables": tables,
}
return renderTemplate(c, dashboardTmpl, data)
}
}
// AdminTable handles GET /admin/tables/:name/ — paginated table view.
func AdminTable(db *sql.DB) echo.HandlerFunc {
return func(c echo.Context) error {
table := c.Param("name")
if !isKnownTable(table) {
return c.JSON(http.StatusNotFound, echo.Map{"error": "table not found"})
}
cols, err := getColumns(db, table)
if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
}
if len(cols) == 0 {
return c.JSON(http.StatusNotFound, echo.Map{"error": "table not found"})
}
// Pagination.
page, _ := strconv.Atoi(c.QueryParam("page"))
if page < 1 {
page = 1
}
perPage := 50
offset := (page - 1) * perPage
// Total count.
var total int
if err := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&total); err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
}
// Sort.
sortCol := c.QueryParam("sort")
sortDir := c.QueryParam("dir")
orderClause := ""
validSort := false
for _, col := range cols {
if col.Name == sortCol {
validSort = true
break
}
}
if validSort {
dir := "ASC"
if strings.EqualFold(sortDir, "desc") {
dir = "DESC"
}
orderClause = fmt.Sprintf(" ORDER BY %s %s", sortCol, dir)
} else {
// Default: order by primary key descending.
pk := getPKColumn(cols)
if pk != "" {
orderClause = fmt.Sprintf(" ORDER BY %s DESC", pk)
sortCol = pk
sortDir = "desc"
}
}
// Search filter.
search := strings.TrimSpace(c.QueryParam("q"))
whereClause := ""
var args []interface{}
if search != "" {
var conditions []string
for _, col := range cols {
conditions = append(conditions, fmt.Sprintf("CAST(%s AS TEXT) LIKE ?", col.Name))
args = append(args, "%"+search+"%")
}
whereClause = " WHERE " + strings.Join(conditions, " OR ")
// Recount with filter.
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s%s", table, whereClause)
if err := db.QueryRow(countQuery, args...).Scan(&total); err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
}
}
query := fmt.Sprintf("SELECT * FROM %s%s%s LIMIT ? OFFSET ?", table, whereClause, orderClause)
queryArgs := append(args, perPage, offset)
rows, err := db.Query(query, queryArgs...)
if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
}
defer rows.Close()
var rowData [][]string
colNames, _ := rows.Columns()
for rows.Next() {
vals := make([]interface{}, len(colNames))
ptrs := make([]interface{}, len(colNames))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "scan error"})
}
row := make([]string, len(colNames))
for i, v := range vals {
if v == nil {
row[i] = "NULL"
} else {
row[i] = fmt.Sprintf("%v", v)
}
}
rowData = append(rowData, row)
}
totalPages := (total + perPage - 1) / perPage
pk := getPKColumn(cols)
// Find PK column index for linking.
pkIdx := -1
for i, col := range cols {
if col.Name == pk {
pkIdx = i
break
}
}
data := map[string]interface{}{
"Table": table,
"Columns": colNames,
"Rows": rowData,
"Page": page,
"TotalPages": totalPages,
"Total": total,
"PerPage": perPage,
"Sort": sortCol,
"Dir": sortDir,
"Search": search,
"PKIndex": pkIdx,
"Tables": knownTables,
}
return renderTemplate(c, tableTmpl, data)
}
}
// AdminRow handles GET /admin/tables/:name/:pk/ — single row detail.
func AdminRow(db *sql.DB) echo.HandlerFunc {
return func(c echo.Context) error {
table := c.Param("name")
pkVal := c.Param("pk")
if !isKnownTable(table) {
return c.JSON(http.StatusNotFound, echo.Map{"error": "table not found"})
}
cols, err := getColumns(db, table)
if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
}
pk := getPKColumn(cols)
if pk == "" {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "no primary key"})
}
query := fmt.Sprintf("SELECT * FROM %s WHERE %s = ?", table, pk)
rows, err := db.Query(query, pkVal)
if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
}
defer rows.Close()
colNames, _ := rows.Columns()
if !rows.Next() {
return c.JSON(http.StatusNotFound, echo.Map{"error": "row not found"})
}
vals := make([]interface{}, len(colNames))
ptrs := make([]interface{}, len(colNames))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "scan error"})
}
type field struct {
Name string
Value string
}
var fields []field
for i, name := range colNames {
v := "NULL"
if vals[i] != nil {
v = fmt.Sprintf("%v", vals[i])
}
fields = append(fields, field{Name: name, Value: v})
}
data := map[string]interface{}{
"Table": table,
"PK": pkVal,
"Fields": fields,
"Tables": knownTables,
}
return renderTemplate(c, rowTmpl, data)
}
}
func renderTemplate(c echo.Context, tmpl *template.Template, data interface{}) error {
// Inject the API key so templates can propagate it in links.
if m, ok := data.(map[string]interface{}); ok {
if key, exists := c.Get("api_key").(string); exists {
m["Key"] = key
}
}
c.Response().Header().Set("Content-Type", "text/html; charset=utf-8")
c.Response().WriteHeader(http.StatusOK)
return tmpl.Execute(c.Response().Writer, data)
}
// --- HTML Templates ---
var baseFuncs = template.FuncMap{
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"toggleDir": func(dir string) string {
if strings.EqualFold(dir, "asc") {
return "desc"
}
return "asc"
},
"seq": func(start, end int) []int {
var s []int
for i := start; i <= end; i++ {
s = append(s, i)
}
return s
},
"min": func(a, b int) int {
if a < b {
return a
}
return b
},
"max": func(a, b int) int {
if a > b {
return a
}
return b
},
"eq": func(a, b interface{}) bool {
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
},
"kp": func(key string) template.URL {
if key == "" {
return ""
}
return template.URL("key=" + key)
},
"kpAmp": func(key string) template.URL {
if key == "" {
return ""
}
return template.URL("&key=" + key)
},
}
const cssStyle = `
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
h1 { margin-bottom: 20px; font-size: 24px; color: #111; }
h2 { margin-bottom: 16px; font-size: 20px; color: #111; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
/* Navigation */
.nav { background: #1a1a2e; padding: 12px 20px; margin-bottom: 24px; }
.nav a { color: #e0e0e0; margin-right: 16px; font-size: 14px; }
.nav a:hover { color: #fff; text-decoration: none; }
.nav a.active { color: #fff; font-weight: 600; }
.nav .brand { color: #fff; font-weight: 700; font-size: 16px; margin-right: 32px; }
/* Dashboard cards */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
.card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); transition: box-shadow 0.2s; }
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.card h3 { font-size: 16px; margin-bottom: 8px; }
.card .count { font-size: 28px; font-weight: 700; color: #0066cc; }
.card a { display: block; color: inherit; }
.card a:hover { text-decoration: none; }
/* Table */
.table-wrap { background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { background: #f8f9fa; text-align: left; padding: 10px 12px; border-bottom: 2px solid #dee2e6; white-space: nowrap; position: sticky; top: 0; }
th a { color: #333; }
th .sort-arrow { font-size: 11px; margin-left: 4px; color: #999; }
th .sort-arrow.active { color: #0066cc; }
td { padding: 8px 12px; border-bottom: 1px solid #eee; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
tr:hover td { background: #f8f9fa; }
td.null { color: #999; font-style: italic; }
/* Toolbar */
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }
.toolbar .info { font-size: 14px; color: #666; }
.search-form { display: flex; gap: 8px; }
.search-form input { padding: 6px 12px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; width: 260px; }
.search-form button { padding: 6px 16px; background: #0066cc; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
.search-form button:hover { background: #0055aa; }
/* Pagination */
.pagination { display: flex; justify-content: center; gap: 4px; margin-top: 16px; }
.pagination a, .pagination span { padding: 6px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.pagination a:hover { background: #e9ecef; text-decoration: none; }
.pagination .current { background: #0066cc; color: #fff; border-color: #0066cc; }
.pagination .disabled { color: #ccc; pointer-events: none; }
/* Detail view */
.detail-table { width: 100%; }
.detail-table th { width: 200px; background: #f8f9fa; font-weight: 600; vertical-align: top; }
.detail-table td { word-break: break-all; white-space: normal; }
</style>
`
var dashboardTmpl = template.Must(template.New("dashboard").Funcs(baseFuncs).Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Horchposten Admin</title>` + cssStyle + `</head>
<body>
<div class="nav">
<span class="brand">Horchposten</span>
<a href="/admin/?{{kp .Key}}" class="active">Dashboard</a>
</div>
<div class="container">
<h1>Database</h1>
<div class="cards">
{{range .Tables}}
<div class="card">
<a href="/admin/tables/{{.Name}}/?{{kp $.Key}}">
<h3>{{.Name}}</h3>
<div class="count">{{.Count}}</div>
<div style="font-size:13px;color:#666;margin-top:4px">rows</div>
</a>
</div>
{{end}}
</div>
</div>
</body>
</html>`))
var tableTmpl = template.Must(template.New("table").Funcs(baseFuncs).Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{{.Table}} — Horchposten Admin</title>` + cssStyle + `</head>
<body>
<div class="nav">
<span class="brand">Horchposten</span>
<a href="/admin/?{{kp .Key}}">Dashboard</a>
{{range .Tables}}<a href="/admin/tables/{{.}}/?{{kp $.Key}}"{{if eq . $.Table}} class="active"{{end}}>{{.}}</a>{{end}}
</div>
<div class="container">
<h2>{{.Table}}</h2>
<div class="toolbar">
<div class="info">{{.Total}} rows total — page {{.Page}} of {{.TotalPages}}</div>
<form class="search-form" method="get" action="/admin/tables/{{.Table}}/">
<input type="hidden" name="key" value="{{.Key}}">
<input type="text" name="q" placeholder="Search all columns..." value="{{.Search}}">
<button type="submit">Search</button>
{{if .Search}}<a href="/admin/tables/{{.Table}}/?{{kp .Key}}" style="padding:6px 12px;font-size:14px">Clear</a>{{end}}
</form>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
{{range $i, $col := .Columns}}
<th>
{{if eq $col $.Sort}}
<a href="?sort={{$col}}&dir={{toggleDir $.Dir}}{{if $.Search}}&q={{$.Search}}{{end}}{{kpAmp $.Key}}">
{{$col}} <span class="sort-arrow active">{{if eq $.Dir "asc"}}&#9650;{{else}}&#9660;{{end}}</span>
</a>
{{else}}
<a href="?sort={{$col}}&dir=asc{{if $.Search}}&q={{$.Search}}{{end}}{{kpAmp $.Key}}">
{{$col}} <span class="sort-arrow">&#9650;</span>
</a>
{{end}}
</th>
{{end}}
</tr>
</thead>
<tbody>
{{if not .Rows}}
<tr><td colspan="{{len .Columns}}" style="text-align:center;padding:24px;color:#999">No rows found</td></tr>
{{end}}
{{range .Rows}}
<tr>
{{range $i, $val := .}}
{{if and (ge $.PKIndex 0) (eq $i $.PKIndex)}}
<td><a href="/admin/tables/{{$.Table}}/{{$val}}/?{{kp $.Key}}">{{$val}}</a></td>
{{else if eq $val "NULL"}}
<td class="null">NULL</td>
{{else}}
<td>{{$val}}</td>
{{end}}
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .Page 1}}
<a href="?page={{sub .Page 1}}&sort={{.Sort}}&dir={{.Dir}}{{if .Search}}&q={{.Search}}{{end}}{{kpAmp .Key}}">&#8592; Prev</a>
{{else}}
<span class="disabled">&#8592; Prev</span>
{{end}}
{{$start := max 1 (sub .Page 2)}}
{{$end := min .TotalPages (add .Page 2)}}
{{if gt $start 1}}<a href="?page=1&sort={{.Sort}}&dir={{.Dir}}{{if .Search}}&q={{.Search}}{{end}}{{kpAmp .Key}}">1</a>{{if gt $start 2}}<span>...</span>{{end}}{{end}}
{{range seq $start $end}}
{{if eq . $.Page}}
<span class="current">{{.}}</span>
{{else}}
<a href="?page={{.}}&sort={{$.Sort}}&dir={{$.Dir}}{{if $.Search}}&q={{$.Search}}{{end}}{{kpAmp $.Key}}">{{.}}</a>
{{end}}
{{end}}
{{if lt $end $.TotalPages}}{{if lt (add $end 1) $.TotalPages}}<span>...</span>{{end}}<a href="?page={{$.TotalPages}}&sort={{.Sort}}&dir={{.Dir}}{{if .Search}}&q={{.Search}}{{end}}{{kpAmp .Key}}">{{$.TotalPages}}</a>{{end}}
{{if lt .Page .TotalPages}}
<a href="?page={{add .Page 1}}&sort={{.Sort}}&dir={{.Dir}}{{if .Search}}&q={{.Search}}{{end}}{{kpAmp .Key}}">Next &#8594;</a>
{{else}}
<span class="disabled">Next &#8594;</span>
{{end}}
</div>
{{end}}
</div>
</body>
</html>`))
var rowTmpl = template.Must(template.New("row").Funcs(baseFuncs).Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{{.Table}} #{{.PK}} — Horchposten Admin</title>` + cssStyle + `</head>
<body>
<div class="nav">
<span class="brand">Horchposten</span>
<a href="/admin/?{{kp .Key}}">Dashboard</a>
{{range .Tables}}<a href="/admin/tables/{{.}}/?{{kp $.Key}}"{{if eq . $.Table}} class="active"{{end}}>{{.}}</a>{{end}}
</div>
<div class="container">
<h2><a href="/admin/tables/{{.Table}}/?{{kp .Key}}">{{.Table}}</a> &rsaquo; {{.PK}}</h2>
<div class="table-wrap">
<table class="detail-table">
<tbody>
{{range .Fields}}
<tr>
<th>{{.Name}}</th>
{{if eq .Value "NULL"}}
<td class="null">NULL</td>
{{else}}
<td>{{.Value}}</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</body>
</html>`))