added media fixes, search, push updates, pwa, metrics. preparing release 1.0
This commit is contained in:
@@ -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", ""), ",") {
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user