added media fixes, search, push updates, pwa, metrics. preparing release 1.0
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-27 23:26:15 +03:00
parent 855a62755c
commit 6fc6d977bf
59 changed files with 6823 additions and 113 deletions
+15 -6
View File
@@ -26,18 +26,27 @@ type Config struct {
JWTSecret string
// MediaDir is the local disk location for uploaded attachments.
MediaDir string
// VAPIDPublicKey and VAPIDPrivateKey enable Web Push. When either is
// empty, push notifications are disabled.
VAPIDPublicKey string
VAPIDPrivateKey string
// VAPIDSubject is the contact address used in the VAPID JWT.
VAPIDSubject string
}
// Load reads configuration from the environment, falling back to
// development-friendly defaults. Unknown values are validated.
func Load() (*Config, error) {
cfg := &Config{
HTTPAddr: fromEnv("MESSENGER_HTTP_ADDR", ":8080"),
StaticDir: os.Getenv("MESSENGER_STATIC_DIR"),
Environment: fromEnv("MESSENGER_ENV", "development"),
DatabaseURL: fromEnv("MESSENGER_DATABASE_URL", "postgres://messenger:messenger@localhost:5432/messenger?sslmode=disable"),
JWTSecret: fromEnv("MESSENGER_JWT_SECRET", "dev-secret-change-me"),
MediaDir: fromEnv("MESSENGER_MEDIA_DIR", "./media"),
HTTPAddr: fromEnv("MESSENGER_HTTP_ADDR", ":8080"),
StaticDir: os.Getenv("MESSENGER_STATIC_DIR"),
Environment: fromEnv("MESSENGER_ENV", "development"),
DatabaseURL: fromEnv("MESSENGER_DATABASE_URL", "postgres://messenger:messenger@localhost:5432/messenger?sslmode=disable"),
JWTSecret: fromEnv("MESSENGER_JWT_SECRET", "dev-secret-change-me"),
MediaDir: fromEnv("MESSENGER_MEDIA_DIR", "./media"),
VAPIDPublicKey: os.Getenv("MESSENGER_VAPID_PUBLIC_KEY"),
VAPIDPrivateKey: os.Getenv("MESSENGER_VAPID_PRIVATE_KEY"),
VAPIDSubject: os.Getenv("MESSENGER_VAPID_SUBJECT"),
}
for _, origin := range strings.Split(fromEnv("MESSENGER_ALLOWED_ORIGINS", ""), ",") {
+107
View File
@@ -0,0 +1,107 @@
// Package metrics exposes Prometheus instrumentation for the HTTP API
// and the realtime hub.
package metrics
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
requestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "messenger_http_requests_total",
Help: "HTTP requests handled, partitioned by method, normalized path and status.",
},
[]string{"method", "path", "status"},
)
requestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "messenger_http_request_duration_seconds",
Help: "HTTP request latency.",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "path"},
)
wsConnections = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "messenger_ws_connections",
Help: "Current number of open WebSocket connections.",
},
)
buildInfo = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "messenger_build_info",
Help: "Build information.",
},
[]string{"version"},
)
)
// Init registers all custom metrics. The default registry already carries
// the standard Go and process collectors.
func Init(version string) {
prometheus.MustRegister(requestsTotal, requestDuration, wsConnections, buildInfo)
buildInfo.WithLabelValues(version).Set(1)
}
// Handler returns the Prometheus scrape endpoint.
func Handler() http.Handler {
return promhttp.Handler()
}
// RecordWS increments or decrements the WebSocket connection gauge.
func RecordWS(delta float64) {
wsConnections.Add(delta)
}
// Middleware instruments every request with counters and a latency histogram.
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
path := NormalizePath(r.URL.Path)
requestsTotal.WithLabelValues(r.Method, path, strconv.Itoa(rec.status)).Inc()
requestDuration.WithLabelValues(r.Method, path).Observe(time.Since(start).Seconds())
})
}
type recorder struct {
http.ResponseWriter
status int
}
func (r *recorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// NormalizePath replaces numeric path segments with a placeholder so that
// per-route cardinality stays bounded regardless of resource IDs.
func NormalizePath(p string) string {
parts := strings.Split(p, "/")
for i, part := range parts {
if part != "" && isNumeric(part) {
parts[i] = "{id}"
}
}
return strings.Join(parts, "/")
}
func isNumeric(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
@@ -0,0 +1,61 @@
// Package ratelimit provides a simple in-memory fixed-window limiter
// keyed by client IP, used to protect authentication endpoints.
package ratelimit
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// Limiter is a fixed-window, per-IP request limiter.
type Limiter struct {
mu sync.Mutex
max int
window time.Duration
hits map[string]*hits
}
type hits struct {
windowStart time.Time
count int
}
// New returns a limiter allowing max requests per window per IP.
func New(max int, window time.Duration) *Limiter {
return &Limiter{max: max, window: window, hits: make(map[string]*hits)}
}
// Allow reports whether the request passes the limit, consuming one slot.
func (l *Limiter) Allow(r *http.Request) bool {
ip := ClientIP(r)
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
h := l.hits[ip]
if h == nil || now.Sub(h.windowStart) >= l.window {
h = &hits{windowStart: now}
l.hits[ip] = h
}
h.count++
return h.count <= l.max
}
// ClientIP extracts the remote address, preferring X-Forwarded-For when
// running behind a reverse proxy.
func ClientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.IndexByte(fwd, ','); i > 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
@@ -0,0 +1,66 @@
package ratelimit
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestLimiterAllowsUpToMax(t *testing.T) {
l := New(3, time.Minute)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "1.2.3.4:1234"
for i := 0; i < 3; i++ {
if !l.Allow(req) {
t.Fatalf("request %d rejected, want allowed", i+1)
}
}
if l.Allow(req) {
t.Fatal("4th request allowed, want rejected")
}
}
func TestLimiterSeparatesIPs(t *testing.T) {
l := New(2, time.Minute)
reqA := httptest.NewRequest(http.MethodGet, "/", nil)
reqA.RemoteAddr = "1.1.1.1:1"
reqB := httptest.NewRequest(http.MethodGet, "/", nil)
reqB.RemoteAddr = "2.2.2.2:2"
l.Allow(reqA)
l.Allow(reqA)
if l.Allow(reqA) {
t.Fatal("IP A over limit, want rejected")
}
if !l.Allow(reqB) {
t.Fatal("IP B should have its own budget")
}
}
func TestLimiterWindowResets(t *testing.T) {
l := New(1, 50*time.Millisecond)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "9.9.9.9:9"
if !l.Allow(req) {
t.Fatal("first request rejected")
}
if l.Allow(req) {
t.Fatal("second request within window allowed, want rejected")
}
time.Sleep(60 * time.Millisecond)
if !l.Allow(req) {
t.Fatal("request after window reset rejected")
}
}
func TestClientIPPrefersForwarded(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.5:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
if got := ClientIP(req); got != "203.0.113.9" {
t.Fatalf("ClientIP = %q, want 203.0.113.9", got)
}
}
@@ -0,0 +1,6 @@
// Package version holds the application version, single source of truth
// for release tooling and runtime metrics.
package version
// Version is the semantic version of the messenger server.
const Version = "1.0.0"