added media fixes, search, push updates, pwa, metrics. preparing release 1.0
This commit is contained in:
@@ -7,6 +7,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -17,6 +18,8 @@ import (
|
||||
"messenger/internal/media"
|
||||
"messenger/internal/platform/config"
|
||||
"messenger/internal/platform/logger"
|
||||
"messenger/internal/platform/metrics"
|
||||
"messenger/internal/platform/version"
|
||||
"messenger/internal/realtime"
|
||||
"messenger/internal/server"
|
||||
"messenger/internal/store"
|
||||
@@ -33,6 +36,9 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log.Info("starting messenger", "version", version.Version)
|
||||
metrics.Init(version.Version)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -59,6 +65,8 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
go cleanupLoop(ctx, st, log)
|
||||
|
||||
httpServer := server.New(cfg, log, st, tm, hub, mediaStore)
|
||||
|
||||
// Run the server in a goroutine so that shutdown can be triggered
|
||||
@@ -88,3 +96,21 @@ func main() {
|
||||
}
|
||||
log.Info("server stopped cleanly")
|
||||
}
|
||||
|
||||
// cleanupLoop periodically purges expired sessions and revocations.
|
||||
func cleanupLoop(ctx context.Context, st *store.Store, log *slog.Logger) {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if n, err := st.CleanupSessions(ctx); err != nil {
|
||||
log.Error("session cleanup failed", "error", err)
|
||||
} else if n > 0 {
|
||||
log.Info("cleaned up sessions", "count", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Command keygen prints fresh VAPID keys for Web Push configuration.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
private, public, err := webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
fmt.Println("failed to generate keys:", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("MESSENGER_VAPID_PUBLIC_KEY=" + public)
|
||||
fmt.Println("MESSENGER_VAPID_PRIVATE_KEY=" + private)
|
||||
fmt.Println("MESSENGER_VAPID_SUBJECT=mailto:you@example.com")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Command seed creates demo users, a family group and a few messages.
|
||||
// It is idempotent: existing usernames are left untouched.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"messenger/internal/auth"
|
||||
"messenger/internal/platform/config"
|
||||
"messenger/internal/platform/logger"
|
||||
"messenger/internal/store"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type seedUser struct {
|
||||
username string
|
||||
displayName string
|
||||
password string
|
||||
}
|
||||
|
||||
var demoUsers = []seedUser{
|
||||
{"papa", "Папа", "password123"},
|
||||
{"mama", "Мама", "password123"},
|
||||
{"syn", "Сын", "password123"},
|
||||
{"doch", "Дочь", "password123"},
|
||||
}
|
||||
|
||||
func main() {
|
||||
log := logger.New()
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Error("invalid configuration", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Error("failed to connect", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
st := store.New(pool)
|
||||
if err := st.Migrate(ctx); err != nil {
|
||||
log.Error("migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
created, err := ensureUsers(ctx, st)
|
||||
if err != nil {
|
||||
log.Error("seed users failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("users created: %d\n", created)
|
||||
|
||||
group, err := ensureGroup(ctx, st)
|
||||
if err != nil {
|
||||
log.Error("seed group failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("group ready: %s (id=%d)\n", group.Name, group.ID)
|
||||
}
|
||||
|
||||
func ensureUsers(ctx context.Context, st *store.Store) (int, error) {
|
||||
created := 0
|
||||
for _, u := range demoUsers {
|
||||
if _, err := st.GetUserByUsername(ctx, u.username); err == nil {
|
||||
continue
|
||||
}
|
||||
hash, err := auth.HashPassword(u.password)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
if _, err := st.CreateUser(ctx, u.username, u.displayName, hash); err != nil {
|
||||
return created, err
|
||||
}
|
||||
created++
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func ensureGroup(ctx context.Context, st *store.Store) (*store.Conversation, error) {
|
||||
users := make([]int64, 0, len(demoUsers))
|
||||
var ownerID int64
|
||||
for _, u := range demoUsers {
|
||||
user, err := st.GetUserByUsername(ctx, u.username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user %s missing: %w", u.username, err)
|
||||
}
|
||||
if ownerID == 0 {
|
||||
ownerID = user.ID
|
||||
}
|
||||
users = append(users, user.ID)
|
||||
}
|
||||
|
||||
// Reuse an existing group with the same name if present.
|
||||
conversations, err := st.ListConversationsForUser(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range conversations {
|
||||
if conversations[i].Type == "group" && conversations[i].Name == "Семья" {
|
||||
return &conversations[i].Conversation, nil
|
||||
}
|
||||
}
|
||||
|
||||
group, err := st.CreateGroup(ctx, ownerID, "Семья", users[1:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := st.InsertMessage(ctx, group.ID, ownerID, "Всем привет! Это наша семейная группа.", nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
@@ -3,19 +3,28 @@ module messenger
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/image v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -15,23 +25,106 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
+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