added media fixes, search, push updates, pwa, metrics. preparing release 1.0
This commit is contained in:
+35
-15
@@ -4,49 +4,66 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"messenger/internal/auth"
|
||||
"messenger/internal/media"
|
||||
"messenger/internal/platform/config"
|
||||
"messenger/internal/platform/ratelimit"
|
||||
"messenger/internal/realtime"
|
||||
"messenger/internal/store"
|
||||
)
|
||||
|
||||
type apiHandler struct {
|
||||
store *store.Store
|
||||
tokens *auth.TokenManager
|
||||
hub *realtime.Hub
|
||||
log *slog.Logger
|
||||
secure bool
|
||||
media media.Storage
|
||||
store *store.Store
|
||||
tokens *auth.TokenManager
|
||||
hub *realtime.Hub
|
||||
log *slog.Logger
|
||||
secure bool
|
||||
media media.Storage
|
||||
notifier *pushNotifier
|
||||
}
|
||||
|
||||
// NewHandler builds the /api/v1 route tree with all middleware applied.
|
||||
// static, when non-nil, serves the built frontend for non-API paths.
|
||||
func NewHandler(st *store.Store, tm *auth.TokenManager, hub *realtime.Hub, log *slog.Logger, cfg *config.Config, ms media.Storage, static http.Handler) http.Handler {
|
||||
h := &apiHandler{
|
||||
store: st,
|
||||
tokens: tm,
|
||||
hub: hub,
|
||||
log: log,
|
||||
secure: cfg.Environment == "production",
|
||||
media: ms,
|
||||
store: st,
|
||||
tokens: tm,
|
||||
hub: hub,
|
||||
log: log,
|
||||
secure: cfg.Environment == "production",
|
||||
media: ms,
|
||||
notifier: newPushNotifier(st, hub, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
authHandler := requireAuth(tm, http.HandlerFunc(h.websocket))
|
||||
|
||||
authLimiter := ratelimit.New(20, time.Minute)
|
||||
limited := func(inner http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !authLimiter.Allow(r) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
writeError(w, http.StatusTooManyRequests, "too many requests")
|
||||
return
|
||||
}
|
||||
inner(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
mux.HandleFunc("GET /api/v1/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "pong"})
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /api/v1/auth/register", h.register)
|
||||
mux.HandleFunc("POST /api/v1/auth/login", h.login)
|
||||
mux.HandleFunc("POST /api/v1/auth/refresh", h.refresh)
|
||||
mux.HandleFunc("POST /api/v1/auth/register", limited(h.register))
|
||||
mux.HandleFunc("POST /api/v1/auth/login", limited(h.login))
|
||||
mux.HandleFunc("POST /api/v1/auth/refresh", limited(h.refresh))
|
||||
mux.HandleFunc("POST /api/v1/auth/logout", h.logout)
|
||||
|
||||
mux.Handle("GET /api/v1/users/me", requireAuth(tm, h.me))
|
||||
mux.Handle("GET /api/v1/users", requireAuth(tm, h.searchUsers))
|
||||
mux.Handle("GET /api/v1/search/messages", requireAuth(tm, h.searchMessages))
|
||||
|
||||
mux.Handle("GET /api/v1/contacts", requireAuth(tm, h.listContacts))
|
||||
mux.Handle("POST /api/v1/contacts", requireAuth(tm, h.addContact))
|
||||
@@ -66,6 +83,9 @@ func NewHandler(st *store.Store, tm *auth.TokenManager, hub *realtime.Hub, log *
|
||||
mux.Handle("GET /api/v1/media/{id}", requireAuth(tm, h.downloadMedia))
|
||||
mux.Handle("GET /api/v1/media/{id}/thumb", requireAuth(tm, h.downloadThumb))
|
||||
|
||||
mux.Handle("POST /api/v1/push/subscribe", requireAuth(tm, h.subscribePush))
|
||||
mux.Handle("DELETE /api/v1/push/subscribe", requireAuth(tm, h.unsubscribePush))
|
||||
|
||||
mux.Handle("GET /ws", authHandler)
|
||||
|
||||
if static != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -39,8 +38,7 @@ type credentialsRequest struct {
|
||||
|
||||
func (h *apiHandler) register(w http.ResponseWriter, r *http.Request) {
|
||||
var req credentialsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if !usernameRe.MatchString(req.Username) {
|
||||
@@ -76,8 +74,7 @@ func (h *apiHandler) register(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *apiHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||
var req credentialsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
user, err := h.store.GetUserByUsername(r.Context(), req.Username)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -51,7 +50,10 @@ func (h *apiHandler) addContact(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.UserID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -105,8 +104,7 @@ type createConversationRequest struct {
|
||||
func (h *apiHandler) createConversation(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
var req createConversationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -212,7 +210,10 @@ func (h *apiHandler) addMember(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.UserID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
@@ -358,8 +359,7 @@ func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req sendMessageRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Body == "" && req.MediaID == nil {
|
||||
@@ -401,6 +401,14 @@ func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
h.hub.PublishMessageCreated(convID, wsEvent)
|
||||
|
||||
if h.notifier != nil {
|
||||
senderName := ""
|
||||
if sender, err := h.store.GetUserByID(r.Context(), userID); err == nil {
|
||||
senderName = sender.DisplayName
|
||||
}
|
||||
h.notifier.notifyMembers(r.Context(), convID, userID, msg.ID, senderName, msg.Body)
|
||||
}
|
||||
|
||||
out := toMessageJSON(msg)
|
||||
if attached != nil {
|
||||
md := toMediaJSON(attached)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
|
||||
"messenger/internal/realtime"
|
||||
"messenger/internal/store"
|
||||
)
|
||||
|
||||
type pushRequest struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys struct {
|
||||
P256dh string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
func (h *apiHandler) subscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
var req pushRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" {
|
||||
writeError(w, http.StatusBadRequest, "endpoint, p256dh and auth are required")
|
||||
return
|
||||
}
|
||||
_, err := h.store.CreatePushSubscription(r.Context(), store.PushSubscription{
|
||||
UserID: userID,
|
||||
Endpoint: req.Endpoint,
|
||||
P256dh: req.Keys.P256dh,
|
||||
Auth: req.Keys.Auth,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *apiHandler) unsubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
var req pushRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Endpoint == "" {
|
||||
writeError(w, http.StatusBadRequest, "endpoint is required")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeletePushSubscription(r.Context(), userID, req.Endpoint); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
type pushNotifier struct {
|
||||
store *store.Store
|
||||
hub *realtime.Hub
|
||||
public string
|
||||
private string
|
||||
subject string
|
||||
}
|
||||
|
||||
func newPushNotifier(st *store.Store, hub *realtime.Hub, public, private, subject string) *pushNotifier {
|
||||
if public == "" || private == "" {
|
||||
return nil
|
||||
}
|
||||
return &pushNotifier{store: st, hub: hub, public: public, private: private, subject: subject}
|
||||
}
|
||||
|
||||
// notifyMembers pushes a message notification to offline members.
|
||||
// Failed or stale endpoints are cleaned up asynchronously.
|
||||
func (n *pushNotifier) notifyMembers(ctx context.Context, conversationID, senderID, messageID int64, senderName, body string) {
|
||||
members, err := n.store.ListMembers(ctx, conversationID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
title := senderName
|
||||
text := strings.TrimSpace(body)
|
||||
if text == "" {
|
||||
text = "📎 Вложение"
|
||||
}
|
||||
if len(text) > 140 {
|
||||
text = text[:140] + "…"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"title": title,
|
||||
"body": text,
|
||||
"conversation_id": conversationID,
|
||||
"message_id": messageID,
|
||||
})
|
||||
|
||||
for _, m := range members {
|
||||
if m.User.ID == senderID || n.hub.IsOnline(m.User.ID) {
|
||||
continue
|
||||
}
|
||||
subs, err := n.store.ListPushSubscriptionsForUser(ctx, m.User.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, sub := range subs {
|
||||
go n.send(ctx, m.User.ID, sub, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n *pushNotifier) send(ctx context.Context, userID int64, sub store.PushSubscription, payload []byte) {
|
||||
wp := &webpush.Subscription{Endpoint: sub.Endpoint, Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth}}
|
||||
resp, err := webpush.SendNotificationWithContext(ctx, payload, wp, &webpush.Options{
|
||||
VAPIDPublicKey: n.public,
|
||||
VAPIDPrivateKey: n.private,
|
||||
Subscriber: n.subject,
|
||||
TTL: 24 * 60 * 60,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
|
||||
_ = n.store.DeletePushSubscription(context.Background(), userID, sub.Endpoint)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
@@ -15,8 +14,7 @@ func (h *apiHandler) addReaction(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Emoji string `json:"emoji"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.Emoji) == 0 || len(req.Emoji) > 32 {
|
||||
|
||||
@@ -31,3 +31,13 @@ func writeStoreError(w http.ResponseWriter, err error) {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
// decodeJSON parses the request body into dst, rejecting oversized payloads.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type searchHitJSON struct {
|
||||
Message messageJSON `json:"message"`
|
||||
Conversation conversationRef `json:"conversation"`
|
||||
}
|
||||
|
||||
type conversationRef struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *apiHandler) searchMessages(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
query := r.URL.Query().Get("q")
|
||||
if len(query) < 2 {
|
||||
writeJSON(w, http.StatusOK, []searchHitJSON{})
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
hits, err := h.store.SearchMessages(r.Context(), userID, query, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]searchHitJSON, 0, len(hits))
|
||||
mediaByID := make(map[int64]mediaJSON)
|
||||
for _, hit := range hits {
|
||||
if hit.MediaID != nil {
|
||||
if _, ok := mediaByID[*hit.MediaID]; !ok {
|
||||
if m, err := h.store.GetMediaByID(r.Context(), *hit.MediaID); err == nil {
|
||||
mediaByID[*hit.MediaID] = toMediaJSON(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, hit := range hits {
|
||||
msg := toMessageJSON(&hit.Message)
|
||||
if hit.MediaID != nil {
|
||||
if md, ok := mediaByID[*hit.MediaID]; ok {
|
||||
msg.Media = &md
|
||||
}
|
||||
}
|
||||
out = append(out, searchHitJSON{
|
||||
Message: msg,
|
||||
Conversation: conversationRef{
|
||||
ID: hit.ConversationID,
|
||||
Type: hit.ConversationType,
|
||||
Name: hit.ConversationName,
|
||||
},
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"messenger/internal/platform/metrics"
|
||||
)
|
||||
|
||||
// MembershipProvider resolves conversation membership and read state.
|
||||
@@ -145,6 +147,13 @@ func (h *Hub) PublishReactionChanged(conversationID, messageID, userID int64, em
|
||||
h.sendToConversation(conversationID, envelope{Type: evtType, Data: mustMarshal(evt)}, 0)
|
||||
}
|
||||
|
||||
// IsOnline reports whether a user has at least one active WebSocket connection.
|
||||
func (h *Hub) IsOnline(userID int64) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.conns[userID]) > 0
|
||||
}
|
||||
|
||||
func (h *Hub) register(c *client, conversationIDs []int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -158,6 +167,7 @@ func (h *Hub) register(c *client, conversationIDs []int64) {
|
||||
}
|
||||
h.subs[cid][c.userID] = struct{}{}
|
||||
}
|
||||
metrics.RecordWS(1)
|
||||
}
|
||||
|
||||
func (h *Hub) unregister(c *client, conversationIDs []int64) {
|
||||
@@ -177,6 +187,7 @@ func (h *Hub) unregister(c *client, conversationIDs []int64) {
|
||||
}
|
||||
}
|
||||
}
|
||||
metrics.RecordWS(-1)
|
||||
close(c.done)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,19 @@ func recoverPanics(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// securityHeaders sets hardening headers on every response.
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"messenger/internal/auth"
|
||||
"messenger/internal/media"
|
||||
"messenger/internal/platform/config"
|
||||
"messenger/internal/platform/metrics"
|
||||
"messenger/internal/realtime"
|
||||
"messenger/internal/store"
|
||||
)
|
||||
@@ -28,6 +29,8 @@ func New(cfg *config.Config, log *slog.Logger, st *store.Store, tm *auth.TokenMa
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
})
|
||||
|
||||
mux.Handle("GET /metrics", metrics.Handler())
|
||||
|
||||
var static http.Handler
|
||||
if cfg.StaticDir != "" {
|
||||
static = spaHandler{staticDir: cfg.StaticDir, log: log}
|
||||
@@ -35,7 +38,7 @@ func New(cfg *config.Config, log *slog.Logger, st *store.Store, tm *auth.TokenMa
|
||||
|
||||
mux.Handle("/", api.NewHandler(st, tm, hub, log, cfg, ms, static))
|
||||
|
||||
handler := logRequests(log, recoverPanics(cors(cfg.AllowedOrigins, mux)))
|
||||
handler := metrics.Middleware(securityHeaders(logRequests(log, recoverPanics(cors(cfg.AllowedOrigins, mux)))))
|
||||
|
||||
return &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
|
||||
@@ -181,4 +181,59 @@ func TestIntegration(t *testing.T) {
|
||||
if err != nil || !belongs {
|
||||
t.Fatalf("MessageBelongsToConversation = %v, %v; want true", belongs, err)
|
||||
}
|
||||
|
||||
// Search
|
||||
hits, err := db.SearchMessages(ctx, u1.ID, "hell", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchMessages: %v", err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].ID != msg.ID {
|
||||
t.Fatalf("SearchMessages = %+v, want single hit for message %d", hits, msg.ID)
|
||||
}
|
||||
if hits[0].ConversationType != "dm" {
|
||||
t.Fatalf("SearchMessages conversation type = %q, want dm", hits[0].ConversationType)
|
||||
}
|
||||
if hits, _ := db.SearchMessages(ctx, u1.ID, "", 10); hits != nil {
|
||||
t.Fatalf("SearchMessages empty query = %v, want nil", hits)
|
||||
}
|
||||
if hits, _ := db.SearchMessages(ctx, u1.ID, "zzz-not-found", 10); len(hits) != 0 {
|
||||
t.Fatalf("SearchMessages no-match = %d hits, want 0", len(hits))
|
||||
}
|
||||
|
||||
// Push subscriptions
|
||||
sub, err := db.CreatePushSubscription(ctx, PushSubscription{
|
||||
UserID: u1.ID,
|
||||
Endpoint: "https://push.example/abc",
|
||||
P256dh: "p256dh-key",
|
||||
Auth: "auth-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePushSubscription: %v", err)
|
||||
}
|
||||
sub2, err := db.CreatePushSubscription(ctx, PushSubscription{
|
||||
UserID: u1.ID,
|
||||
Endpoint: "https://push.example/abc",
|
||||
P256dh: "p256dh-key2",
|
||||
Auth: "auth-key2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePushSubscription upsert: %v", err)
|
||||
}
|
||||
if sub2.ID != sub.ID {
|
||||
t.Fatalf("upsert created a new row: %d vs %d", sub2.ID, sub.ID)
|
||||
}
|
||||
subs, err := db.ListPushSubscriptionsForUser(ctx, u1.ID)
|
||||
if err != nil || len(subs) != 1 {
|
||||
t.Fatalf("ListPushSubscriptionsForUser = %v, %v; want 1", subs, err)
|
||||
}
|
||||
if subs[0].P256dh != "p256dh-key2" {
|
||||
t.Fatalf("upsert did not update keys: %q", subs[0].P256dh)
|
||||
}
|
||||
if err := db.DeletePushSubscription(ctx, u1.ID, sub.Endpoint); err != nil {
|
||||
t.Fatalf("DeletePushSubscription: %v", err)
|
||||
}
|
||||
subs, _ = db.ListPushSubscriptionsForUser(ctx, u1.ID)
|
||||
if len(subs) != 0 {
|
||||
t.Fatalf("after delete = %d subs, want 0", len(subs))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 0005_push.sql
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
|
||||
@@ -0,0 +1,61 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PushSubscription is a browser Web Push endpoint registered by a user.
|
||||
type PushSubscription struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Endpoint string
|
||||
P256dh string
|
||||
Auth string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreatePushSubscription stores a subscription, replacing any previous one
|
||||
// bound to the same endpoint.
|
||||
func (s *Store) CreatePushSubscription(ctx context.Context, sub PushSubscription) (*PushSubscription, error) {
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO push_subscriptions(user_id, endpoint, p256dh, auth)
|
||||
VALUES($1, $2, $3, $4)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET user_id = EXCLUDED.user_id, p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth
|
||||
RETURNING id, created_at
|
||||
`, sub.UserID, sub.Endpoint, sub.P256dh, sub.Auth).Scan(&sub.ID, &sub.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sub, nil
|
||||
}
|
||||
|
||||
// DeletePushSubscription removes a subscription by endpoint and owner.
|
||||
func (s *Store) DeletePushSubscription(ctx context.Context, userID int64, endpoint string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM push_subscriptions WHERE user_id=$1 AND endpoint=$2
|
||||
`, userID, endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListPushSubscriptionsForUser returns all endpoints registered by a user.
|
||||
func (s *Store) ListPushSubscriptionsForUser(ctx context.Context, userID int64) ([]PushSubscription, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, user_id, endpoint, p256dh, auth, created_at
|
||||
FROM push_subscriptions WHERE user_id=$1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var subs []PushSubscription
|
||||
for rows.Next() {
|
||||
var sub PushSubscription
|
||||
if err := rows.Scan(&sub.ID, &sub.UserID, &sub.Endpoint, &sub.P256dh, &sub.Auth, &sub.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subs = append(subs, sub)
|
||||
}
|
||||
return subs, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// MessageHit is a search result: a message scoped to the member's
|
||||
// conversations, with its conversation context.
|
||||
type MessageHit struct {
|
||||
Message
|
||||
ConversationType string
|
||||
ConversationName string
|
||||
}
|
||||
|
||||
// SearchMessages finds messages matching query in conversations the user
|
||||
// belongs to, newest first. ILIKE substring matching keeps the search
|
||||
// dependency-free at family scale; pg_trgm is the documented scaling path.
|
||||
func (s *Store) SearchMessages(ctx context.Context, userID int64, query string, limit int) ([]MessageHit, error) {
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
pattern := "%" + query + "%"
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT m.id, m.conversation_id, m.sender_id, m.body, m.media_id, m.created_at, m.edited_at, m.deleted_at,
|
||||
c.type, c.name
|
||||
FROM messages m
|
||||
JOIN conversation_members cm ON cm.conversation_id = m.conversation_id AND cm.user_id = $1
|
||||
JOIN conversations c ON c.id = m.conversation_id
|
||||
WHERE m.body ILIKE $2
|
||||
ORDER BY m.id DESC
|
||||
LIMIT $3
|
||||
`, userID, pattern, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hits []MessageHit
|
||||
for rows.Next() {
|
||||
var h MessageHit
|
||||
if err := rows.Scan(
|
||||
&h.ID, &h.ConversationID, &h.SenderID, &h.Body, &h.MediaID, &h.CreatedAt, &h.EditedAt, &h.DeletedAt,
|
||||
&h.ConversationType, &h.ConversationName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hits = append(hits, h)
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
@@ -65,3 +65,16 @@ func (s *Store) RevokeAllSessions(ctx context.Context, userID int64) error {
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanupSessions removes expired sessions and revocations older than a week.
|
||||
func (s *Store) CleanupSessions(ctx context.Context) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM sessions
|
||||
WHERE expires_at < now()
|
||||
OR (revoked_at IS NOT NULL AND revoked_at < now() - interval '7 days')
|
||||
`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user