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)
|
||||
}
|
||||
Reference in New Issue
Block a user