big update: added reactions, groups, media share
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"messenger/internal/auth"
|
||||
"messenger/internal/media"
|
||||
"messenger/internal/platform/config"
|
||||
"messenger/internal/platform/logger"
|
||||
"messenger/internal/realtime"
|
||||
@@ -52,7 +53,13 @@ func main() {
|
||||
tm := auth.NewTokenManager(cfg.JWTSecret)
|
||||
hub := realtime.NewHub(st)
|
||||
|
||||
httpServer := server.New(cfg, log, st, tm, hub)
|
||||
mediaStore, err := media.NewLocal(cfg.MediaDir)
|
||||
if err != nil {
|
||||
log.Error("failed to init media storage", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
httpServer := server.New(cfg, log, st, tm, hub, mediaStore)
|
||||
|
||||
// Run the server in a goroutine so that shutdown can be triggered
|
||||
// from the signal handler below.
|
||||
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/image v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -24,6 +24,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"messenger/internal/auth"
|
||||
"messenger/internal/media"
|
||||
"messenger/internal/platform/config"
|
||||
"messenger/internal/realtime"
|
||||
"messenger/internal/store"
|
||||
@@ -17,17 +18,19 @@ type apiHandler struct {
|
||||
hub *realtime.Hub
|
||||
log *slog.Logger
|
||||
secure bool
|
||||
media media.Storage
|
||||
}
|
||||
|
||||
// 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, static http.Handler) http.Handler {
|
||||
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,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -50,9 +53,18 @@ func NewHandler(st *store.Store, tm *auth.TokenManager, hub *realtime.Hub, log *
|
||||
mux.Handle("DELETE /api/v1/contacts/{user_id}", requireAuth(tm, h.removeContact))
|
||||
|
||||
mux.Handle("GET /api/v1/conversations", requireAuth(tm, h.listConversations))
|
||||
mux.Handle("POST /api/v1/conversations", requireAuth(tm, h.createDM))
|
||||
mux.Handle("POST /api/v1/conversations", requireAuth(tm, h.createConversation))
|
||||
mux.Handle("GET /api/v1/conversations/{id}/members", requireAuth(tm, h.listMembers))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/members", requireAuth(tm, h.addMember))
|
||||
mux.Handle("DELETE /api/v1/conversations/{id}/members/{user_id}", requireAuth(tm, h.removeMember))
|
||||
mux.Handle("GET /api/v1/conversations/{id}/messages", requireAuth(tm, h.listMessages))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/messages", requireAuth(tm, h.sendMessage))
|
||||
mux.Handle("PUT /api/v1/conversations/{id}/messages/{msg_id}/reactions", requireAuth(tm, h.addReaction))
|
||||
mux.Handle("DELETE /api/v1/conversations/{id}/messages/{msg_id}/reactions/{emoji}", requireAuth(tm, h.removeReaction))
|
||||
|
||||
mux.Handle("POST /api/v1/media", requireAuth(tm, h.uploadMedia))
|
||||
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("GET /ws", authHandler)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"messenger/internal/realtime"
|
||||
@@ -13,19 +14,34 @@ import (
|
||||
type conversationJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastMessage *messageJSON `json:"last_message,omitempty"`
|
||||
UnreadCount int64 `json:"unread_count"`
|
||||
}
|
||||
|
||||
type memberJSON struct {
|
||||
User userJSON `json:"user"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
}
|
||||
|
||||
type messageJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
EditedAt *time.Time `json:"edited_at,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
Body string `json:"body"`
|
||||
Reactions []reactionJSON `json:"reactions"`
|
||||
Media *mediaJSON `json:"media,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
EditedAt *time.Time `json:"edited_at,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
type reactionJSON struct {
|
||||
Emoji string `json:"emoji"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
func toMessageJSON(m *store.Message) messageJSON {
|
||||
@@ -34,12 +50,25 @@ func toMessageJSON(m *store.Message) messageJSON {
|
||||
ConversationID: m.ConversationID,
|
||||
SenderID: m.SenderID,
|
||||
Body: m.Body,
|
||||
Reactions: []reactionJSON{},
|
||||
CreatedAt: m.CreatedAt,
|
||||
EditedAt: m.EditedAt,
|
||||
DeletedAt: m.DeletedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func attachReactions(msgs []messageJSON, reactions []store.Reaction) {
|
||||
byMessage := make(map[int64][]reactionJSON)
|
||||
for _, r := range reactions {
|
||||
byMessage[r.MessageID] = append(byMessage[r.MessageID], reactionJSON{Emoji: r.Emoji, UserID: r.UserID})
|
||||
}
|
||||
for i := range msgs {
|
||||
if rs, ok := byMessage[msgs[i].ID]; ok {
|
||||
msgs[i].Reactions = rs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
summaries, err := h.store.ListConversationsForUser(r.Context(), userID)
|
||||
@@ -52,6 +81,8 @@ func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
item := conversationJSON{
|
||||
ID: s.ID,
|
||||
Type: s.Type,
|
||||
Name: s.Name,
|
||||
Avatar: s.Avatar,
|
||||
CreatedAt: s.CreatedAt,
|
||||
UnreadCount: s.UnreadCount,
|
||||
}
|
||||
@@ -64,12 +95,27 @@ func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *apiHandler) createDM(w http.ResponseWriter, r *http.Request) {
|
||||
type createConversationRequest struct {
|
||||
Type string `json:"type"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
MemberIDs []int64 `json:"member_ids"`
|
||||
}
|
||||
|
||||
func (h *apiHandler) createConversation(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
var req struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
var req createConversationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
|
||||
|
||||
if req.Type == "group" || req.Name != "" {
|
||||
h.createGroup(w, r, userID, req)
|
||||
return
|
||||
}
|
||||
|
||||
if req.UserID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
@@ -82,7 +128,148 @@ func (h *apiHandler) createDM(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, conversationJSON{ID: conv.ID, Type: conv.Type, CreatedAt: conv.CreatedAt})
|
||||
writeJSON(w, http.StatusCreated, conversationJSON{
|
||||
ID: conv.ID, Type: conv.Type, Name: conv.Name, Avatar: conv.Avatar, CreatedAt: conv.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *apiHandler) createGroup(w http.ResponseWriter, r *http.Request, userID int64, req createConversationRequest) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" || len(name) > 64 {
|
||||
writeError(w, http.StatusBadRequest, "name must be 1-64 chars")
|
||||
return
|
||||
}
|
||||
for _, uid := range req.MemberIDs {
|
||||
if uid == userID {
|
||||
continue
|
||||
}
|
||||
if _, err := h.store.GetUserByID(r.Context(), uid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid member_id")
|
||||
return
|
||||
}
|
||||
}
|
||||
conv, err := h.store.CreateGroup(r.Context(), userID, name, req.MemberIDs)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
h.hub.Subscribe(userID, conv.ID)
|
||||
for _, uid := range req.MemberIDs {
|
||||
if uid == userID {
|
||||
continue
|
||||
}
|
||||
h.hub.Subscribe(uid, conv.ID)
|
||||
h.hub.SendToUser(uid, realtime.EventConversationAdded, realtime.ConversationChanged{ConversationID: conv.ID})
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, conversationJSON{
|
||||
ID: conv.ID, Type: conv.Type, Name: conv.Name, Avatar: conv.Avatar, CreatedAt: conv.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *apiHandler) listMembers(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || convID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid conversation id")
|
||||
return
|
||||
}
|
||||
if member, err := h.store.IsMember(r.Context(), convID, userID); err != nil || !member {
|
||||
writeError(w, http.StatusForbidden, "not a member")
|
||||
return
|
||||
}
|
||||
members, err := h.store.ListMembers(r.Context(), convID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
out := make([]memberJSON, 0, len(members))
|
||||
for _, m := range members {
|
||||
out = append(out, memberJSON{User: toUserJSON(&m.User), Role: m.Role, JoinedAt: m.JoinedAt})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *apiHandler) addMember(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || convID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid conversation id")
|
||||
return
|
||||
}
|
||||
if member, err := h.store.IsMember(r.Context(), convID, userID); err != nil || !member {
|
||||
writeError(w, http.StatusForbidden, "not a member")
|
||||
return
|
||||
}
|
||||
conv, err := h.store.GetConversationByID(r.Context(), convID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "conversation not found")
|
||||
return
|
||||
}
|
||||
if conv.Type != "group" {
|
||||
writeError(w, http.StatusBadRequest, "only groups support members")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
if _, err := h.store.GetUserByID(r.Context(), req.UserID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if err := h.store.AddMember(r.Context(), convID, req.UserID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
h.hub.Subscribe(req.UserID, convID)
|
||||
h.hub.SendToUser(req.UserID, realtime.EventConversationAdded, realtime.ConversationChanged{ConversationID: convID})
|
||||
h.hub.PublishMemberChanged(convID, req.UserID, true)
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *apiHandler) removeMember(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || convID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid conversation id")
|
||||
return
|
||||
}
|
||||
targetID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64)
|
||||
if err != nil || targetID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
|
||||
requesterRole, err := h.store.MemberRole(r.Context(), convID, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, "not a member")
|
||||
return
|
||||
}
|
||||
targetRole, err := h.store.MemberRole(r.Context(), convID, targetID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "member not found")
|
||||
return
|
||||
}
|
||||
if targetRole == "owner" {
|
||||
writeError(w, http.StatusBadRequest, "cannot remove the owner")
|
||||
return
|
||||
}
|
||||
if userID != targetID && requesterRole != "owner" {
|
||||
writeError(w, http.StatusForbidden, "only the owner can remove others")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.RemoveMember(r.Context(), convID, targetID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
h.hub.Unsubscribe(targetID, convID)
|
||||
h.hub.SendToUser(targetID, realtime.EventConversationRemoved, realtime.ConversationChanged{ConversationID: convID})
|
||||
h.hub.PublishMemberChanged(convID, targetID, false)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *apiHandler) listMessages(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -115,15 +302,46 @@ func (h *apiHandler) listMessages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
out := make([]messageJSON, 0, len(messages))
|
||||
msgIDs := make([]int64, 0, len(messages))
|
||||
mediaIDs := make(map[int64]struct{})
|
||||
for i := range messages {
|
||||
out = append(out, toMessageJSON(&messages[i]))
|
||||
msgIDs = append(msgIDs, messages[i].ID)
|
||||
if messages[i].MediaID != nil {
|
||||
mediaIDs[*messages[i].MediaID] = struct{}{}
|
||||
}
|
||||
}
|
||||
reactions, err := h.store.ReactionsForMessages(r.Context(), msgIDs)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
attachReactions(out, reactions)
|
||||
|
||||
mediaByID := make(map[int64]mediaJSON)
|
||||
for id := range mediaIDs {
|
||||
m, err := h.store.GetMediaByID(r.Context(), id)
|
||||
if err == nil {
|
||||
mediaByID[id] = toMediaJSON(m)
|
||||
}
|
||||
}
|
||||
for i := range out {
|
||||
for j := range messages {
|
||||
if messages[j].ID == out[i].ID && messages[j].MediaID != nil {
|
||||
if md, ok := mediaByID[*messages[j].MediaID]; ok {
|
||||
out[i].Media = &md
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type sendMessageRequest struct {
|
||||
Body string `json:"body"`
|
||||
TempID string `json:"temp_id"`
|
||||
Body string `json:"body"`
|
||||
TempID string `json:"temp_id"`
|
||||
MediaID *int64 `json:"media_id,omitempty"`
|
||||
}
|
||||
|
||||
func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -144,8 +362,8 @@ func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.Body == "" {
|
||||
writeError(w, http.StatusBadRequest, "body is required")
|
||||
if req.Body == "" && req.MediaID == nil {
|
||||
writeError(w, http.StatusBadRequest, "body or media_id is required")
|
||||
return
|
||||
}
|
||||
if len(req.Body) > 4000 {
|
||||
@@ -153,20 +371,50 @@ func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.store.InsertMessage(r.Context(), convID, userID, req.Body)
|
||||
var attached *store.Media
|
||||
if req.MediaID != nil {
|
||||
attached, err = h.store.GetMediaByID(r.Context(), *req.MediaID)
|
||||
if err != nil || attached.UploaderID != userID {
|
||||
writeError(w, http.StatusBadRequest, "invalid media_id")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := h.store.InsertMessage(r.Context(), convID, userID, req.Body, req.MediaID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
h.hub.PublishMessageCreated(convID, realtime.MessageCreated{
|
||||
wsEvent := realtime.MessageCreated{
|
||||
TempID: req.TempID,
|
||||
ConversationID: convID,
|
||||
MessageID: msg.ID,
|
||||
SenderID: userID,
|
||||
Body: msg.Body,
|
||||
CreatedAt: msg.CreatedAt,
|
||||
})
|
||||
}
|
||||
if attached != nil {
|
||||
wsEvent.MediaID = &attached.ID
|
||||
info := toRealtimeMedia(attached)
|
||||
wsEvent.Media = &info
|
||||
}
|
||||
h.hub.PublishMessageCreated(convID, wsEvent)
|
||||
|
||||
writeJSON(w, http.StatusCreated, toMessageJSON(msg))
|
||||
out := toMessageJSON(msg)
|
||||
if attached != nil {
|
||||
md := toMediaJSON(attached)
|
||||
out.Media = &md
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func toRealtimeMedia(m *store.Media) realtime.MediaInfo {
|
||||
return realtime.MediaInfo{
|
||||
ID: m.ID,
|
||||
Filename: m.Filename,
|
||||
ContentType: m.ContentType,
|
||||
Size: m.Size,
|
||||
URL: "/api/v1/media/" + strconv.FormatInt(m.ID, 10),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"messenger/internal/media"
|
||||
"messenger/internal/store"
|
||||
)
|
||||
|
||||
const maxUploadBytes = 25 << 20 // 25 MiB
|
||||
|
||||
type mediaJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
Size int64 `json:"size"`
|
||||
URL string `json:"url"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
}
|
||||
|
||||
func toMediaJSON(m *store.Media) mediaJSON {
|
||||
out := mediaJSON{
|
||||
ID: m.ID,
|
||||
Filename: m.Filename,
|
||||
ContentType: m.ContentType,
|
||||
Size: m.Size,
|
||||
URL: fmt.Sprintf("/api/v1/media/%d", m.ID),
|
||||
}
|
||||
if m.ThumbKey != "" {
|
||||
out.ThumbURL = fmt.Sprintf("/api/v1/media/%d/thumb", m.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uploadMedia accepts a multipart file and stores it, generating a thumbnail
|
||||
// for images. Media is accessible to any authenticated user; conversation
|
||||
// membership checks are a documented family-scale simplification.
|
||||
func (h *apiHandler) uploadMedia(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1<<20)
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "missing file field")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
if len(data) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "empty file")
|
||||
return
|
||||
}
|
||||
if len(data) > maxUploadBytes {
|
||||
writeError(w, http.StatusBadRequest, "file too large (max 25 MiB)")
|
||||
return
|
||||
}
|
||||
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if contentType == "" || strings.HasPrefix(contentType, "application/octet-stream") {
|
||||
contentType = http.DetectContentType(data)
|
||||
}
|
||||
|
||||
id := randomHex(16)
|
||||
ext := filepath.Ext(header.Filename)
|
||||
storageKey := fmt.Sprintf("u%d/%s%s", userID, id, ext)
|
||||
if err := h.media.Save(r.Context(), storageKey, bytes.NewReader(data)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to store file")
|
||||
return
|
||||
}
|
||||
|
||||
thumbKey := ""
|
||||
if media.IsImage(contentType) {
|
||||
thumb, err := media.Generate(r.Context(), bytes.NewReader(data), contentType, 512)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate thumbnail")
|
||||
return
|
||||
}
|
||||
if thumb != nil {
|
||||
thumbKey = fmt.Sprintf("u%d/%s_thumb.jpg", userID, id)
|
||||
if err := h.media.Save(r.Context(), thumbKey, bytes.NewReader(thumb)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to store thumbnail")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
created, err := h.store.CreateMedia(r.Context(), store.Media{
|
||||
UploaderID: userID,
|
||||
Filename: header.Filename,
|
||||
ContentType: contentType,
|
||||
Size: int64(len(data)),
|
||||
StorageKey: storageKey,
|
||||
ThumbKey: thumbKey,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toMediaJSON(created))
|
||||
}
|
||||
|
||||
func (h *apiHandler) downloadMedia(w http.ResponseWriter, r *http.Request) {
|
||||
h.serveStoredFile(w, r, func(m *store.Media) (string, string, int64) {
|
||||
return m.StorageKey, m.ContentType, m.Size
|
||||
})
|
||||
}
|
||||
|
||||
func (h *apiHandler) downloadThumb(w http.ResponseWriter, r *http.Request) {
|
||||
h.serveStoredFile(w, r, func(m *store.Media) (string, string, int64) {
|
||||
if m.ThumbKey == "" {
|
||||
return "", "", 0
|
||||
}
|
||||
return m.ThumbKey, "image/jpeg", 0
|
||||
})
|
||||
}
|
||||
|
||||
func (h *apiHandler) serveStoredFile(w http.ResponseWriter, r *http.Request, pick func(*store.Media) (key, contentType string, size int64)) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
m, err := h.store.GetMediaByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
key, contentType, size := pick(m)
|
||||
if key == "" {
|
||||
writeError(w, http.StatusNotFound, "thumbnail not available")
|
||||
return
|
||||
}
|
||||
rc, err := h.media.Open(r.Context(), key)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
if ext := filepath.Ext(m.Filename); ext != "" {
|
||||
if cd := mime.TypeByExtension(ext); cd == "" {
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", m.Filename))
|
||||
}
|
||||
}
|
||||
if size > 0 {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, rc)
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
buf := make([]byte, n)
|
||||
_, _ = rand.Read(buf)
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (h *apiHandler) addReaction(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
convID, msgID, ok := h.parseMessagePath(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Emoji string `json:"emoji"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if len(req.Emoji) == 0 || len(req.Emoji) > 32 {
|
||||
writeError(w, http.StatusBadRequest, "emoji must be 1-32 chars")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.AddReaction(r.Context(), msgID, userID, req.Emoji); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
h.hub.PublishReactionChanged(convID, msgID, userID, req.Emoji, true)
|
||||
h.writeMessageWithReactions(w, r, convID, msgID)
|
||||
}
|
||||
|
||||
func (h *apiHandler) removeReaction(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
convID, msgID, ok := h.parseMessagePath(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
emoji := r.PathValue("emoji")
|
||||
if emoji == "" {
|
||||
writeError(w, http.StatusBadRequest, "emoji is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.RemoveReaction(r.Context(), msgID, userID, emoji); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
h.hub.PublishReactionChanged(convID, msgID, userID, emoji, false)
|
||||
h.writeMessageWithReactions(w, r, convID, msgID)
|
||||
}
|
||||
|
||||
func (h *apiHandler) parseMessagePath(w http.ResponseWriter, r *http.Request) (convID, msgID int64, ok bool) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || convID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid conversation id")
|
||||
return 0, 0, false
|
||||
}
|
||||
msgID, err = strconv.ParseInt(r.PathValue("msg_id"), 10, 64)
|
||||
if err != nil || msgID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid message id")
|
||||
return 0, 0, false
|
||||
}
|
||||
if member, err := h.store.IsMember(r.Context(), convID, userID); err != nil || !member {
|
||||
writeError(w, http.StatusForbidden, "not a member")
|
||||
return 0, 0, false
|
||||
}
|
||||
belongs, err := h.store.MessageBelongsToConversation(r.Context(), convID, msgID)
|
||||
if err != nil || !belongs {
|
||||
writeError(w, http.StatusNotFound, "message not found")
|
||||
return 0, 0, false
|
||||
}
|
||||
return convID, msgID, true
|
||||
}
|
||||
|
||||
func (h *apiHandler) writeMessageWithReactions(w http.ResponseWriter, r *http.Request, convID, msgID int64) {
|
||||
msg, err := h.store.GetMessageByID(r.Context(), msgID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
msgs := []messageJSON{toMessageJSON(msg)}
|
||||
reactions, err := h.store.ReactionsForMessages(r.Context(), []int64{msgID})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
attachReactions(msgs, reactions)
|
||||
writeJSON(w, http.StatusOK, msgs[0])
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Local stores objects as files under a base directory.
|
||||
type Local struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewLocal creates a Local store rooted at dir, creating it if needed.
|
||||
func NewLocal(dir string) (*Local, error) {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Local{dir: dir}, nil
|
||||
}
|
||||
|
||||
func (l *Local) resolve(key string) (string, error) {
|
||||
clean := filepath.Clean(filepath.Join(l.dir, key))
|
||||
if !hasPrefix(clean, l.dir) {
|
||||
return "", errors.New("media: invalid storage key")
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// Save writes the stream to disk atomically under the base directory.
|
||||
func (l *Local) Save(_ context.Context, key string, r io.Reader) error {
|
||||
path, err := l.resolve(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".upload-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
// Open returns a reader for the object or ErrNotFound.
|
||||
func (l *Local) Open(_ context.Context, key string) (io.ReadCloser, error) {
|
||||
path, err := l.resolve(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Delete removes the object; missing objects are not an error.
|
||||
func (l *Local) Delete(_ context.Context, key string) error {
|
||||
path, err := l.resolve(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasPrefix(p, dir string) bool {
|
||||
rel, err := filepath.Rel(dir, p)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !startsWithDotDot(rel)
|
||||
}
|
||||
|
||||
func startsWithDotDot(rel string) bool {
|
||||
return len(rel) >= 3 && rel[0] == '.' && rel[1] == '.' && (rel[2] == '/' || rel[2] == '\\')
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package media provides file storage and image thumbnail generation
|
||||
// for attachments shared in conversations.
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a stored object does not exist.
|
||||
var ErrNotFound = errors.New("media: not found")
|
||||
|
||||
// Storage is an S3-like object store for attachment bytes.
|
||||
type Storage interface {
|
||||
Save(ctx context.Context, key string, r io.Reader) error
|
||||
Open(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalSaveOpenDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := NewLocal(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocal: %v", err)
|
||||
}
|
||||
|
||||
payload := []byte("hello attachment")
|
||||
if err := store.Save(ctx, "a/b/file.txt", bytes.NewReader(payload)); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
rc, err := store.Open(ctx, "a/b/file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("content = %q, want %q", got, payload)
|
||||
}
|
||||
|
||||
if err := store.Delete(ctx, "a/b/file.txt"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if err := store.Delete(ctx, "a/b/file.txt"); err != nil {
|
||||
t.Fatalf("Delete idempotent: %v", err)
|
||||
}
|
||||
if _, err := store.Open(ctx, "a/b/file.txt"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Open after delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalPathTraversalRejected(t *testing.T) {
|
||||
store, err := NewLocal(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocal: %v", err)
|
||||
}
|
||||
err = store.Save(context.Background(), "../../etc/evil", strings.NewReader("x"))
|
||||
if err == nil {
|
||||
t.Fatal("Save with traversal key = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsImage(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"application/pdf": false,
|
||||
"text/plain": false,
|
||||
}
|
||||
for ct, want := range cases {
|
||||
if got := IsImage(ct); got != want {
|
||||
t.Errorf("IsImage(%q) = %v, want %v", ct, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
const maxUploadSize = 25 << 20 // 25 MiB
|
||||
|
||||
// IsImage reports whether the content type is a decodable image format.
|
||||
func IsImage(contentType string) bool {
|
||||
switch contentType {
|
||||
case "image/jpeg", "image/png", "image/gif", "image/webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Generate returns a JPEG thumbnail of at most maxDim on its larger side.
|
||||
// It returns nil for non-images or when the source is already small enough.
|
||||
func Generate(ctx context.Context, r *bytes.Reader, contentType string, maxDim int) ([]byte, error) {
|
||||
if !IsImage(contentType) {
|
||||
return nil, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
src, err := decodeImage(r, contentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bounds := src.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
if w <= maxDim && h <= maxDim {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
scale := float64(maxDim) / float64(max(w, h))
|
||||
nw, nh := int(float64(w)*scale), int(float64(h)*scale)
|
||||
if nw < 1 {
|
||||
nw = 1
|
||||
}
|
||||
if nh < 1 {
|
||||
nh = 1
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.BiLinear.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil)
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 80}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func decodeImage(r *bytes.Reader, contentType string) (image.Image, error) {
|
||||
if contentType == "image/webp" {
|
||||
return webp.Decode(r)
|
||||
}
|
||||
img, _, err := image.Decode(r)
|
||||
return img, err
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testPNG(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for x := 0; x < w; x++ {
|
||||
for y := 0; y < h; y++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 120, A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("png.Encode: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestGenerateSkipsSmallImage(t *testing.T) {
|
||||
data := testPNG(t, 64, 64)
|
||||
out, err := Generate(context.Background(), bytes.NewReader(data), "image/png", 256)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if out != nil {
|
||||
t.Fatalf("small image produced thumbnail, want nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateResizesLargeImage(t *testing.T) {
|
||||
data := testPNG(t, 512, 512)
|
||||
out, err := Generate(context.Background(), bytes.NewReader(data), "image/png", 256)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("large image produced nil thumbnail")
|
||||
}
|
||||
img, err := jpeg.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("thumbnail is not valid JPEG: %v", err)
|
||||
}
|
||||
b := img.Bounds()
|
||||
if b.Dx() > 256 || b.Dy() > 256 {
|
||||
t.Fatalf("thumbnail size = %dx%d, want <= 256x256", b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateNonImageReturnsNil(t *testing.T) {
|
||||
out, err := Generate(context.Background(), bytes.NewReader([]byte("plain")), "text/plain", 256)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if out != nil {
|
||||
t.Fatal("non-image produced thumbnail, want nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCorruptImageFails(t *testing.T) {
|
||||
_, err := Generate(context.Background(), bytes.NewReader([]byte("not an image")), "image/png", 256)
|
||||
if err == nil {
|
||||
t.Fatal("corrupt image: Generate = nil error, want error")
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ type Config struct {
|
||||
DatabaseURL string
|
||||
// JWTSecret signs access tokens. Must be set in production.
|
||||
JWTSecret string
|
||||
// MediaDir is the local disk location for uploaded attachments.
|
||||
MediaDir string
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment, falling back to
|
||||
@@ -35,6 +37,7 @@ func Load() (*Config, error) {
|
||||
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"),
|
||||
}
|
||||
|
||||
for _, origin := range strings.Split(fromEnv("MESSENGER_ALLOWED_ORIGINS", ""), ",") {
|
||||
|
||||
@@ -9,10 +9,16 @@ import (
|
||||
|
||||
// Event type constants sent over the wire.
|
||||
const (
|
||||
EventMessageCreated = "message.created"
|
||||
EventTyping = "typing"
|
||||
EventPresence = "presence"
|
||||
EventRead = "read"
|
||||
EventMessageCreated = "message.created"
|
||||
EventTyping = "typing"
|
||||
EventPresence = "presence"
|
||||
EventRead = "read"
|
||||
EventConversationAdded = "conversation.added"
|
||||
EventConversationRemoved = "conversation.removed"
|
||||
EventMemberAdded = "member.added"
|
||||
EventMemberRemoved = "member.removed"
|
||||
EventReactionAdded = "reaction.added"
|
||||
EventReactionRemoved = "reaction.removed"
|
||||
)
|
||||
|
||||
// Presence status values.
|
||||
@@ -23,12 +29,24 @@ const (
|
||||
|
||||
// MessageCreated is published when a message is stored.
|
||||
type MessageCreated struct {
|
||||
TempID string `json:"temp_id,omitempty"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
TempID string `json:"temp_id,omitempty"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
Body string `json:"body"`
|
||||
MediaID *int64 `json:"media_id,omitempty"`
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// MediaInfo mirrors attachment metadata delivered with a message event.
|
||||
type MediaInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
Size int64 `json:"size"`
|
||||
URL string `json:"url"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
}
|
||||
|
||||
// Typing is published when a user starts or stops typing in a conversation.
|
||||
@@ -51,6 +69,25 @@ type Read struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
}
|
||||
|
||||
// ConversationChanged is sent to a user when they gain or lose a conversation.
|
||||
type ConversationChanged struct {
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
}
|
||||
|
||||
// MemberChanged is published when a user joins or leaves a conversation.
|
||||
type MemberChanged struct {
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
// ReactionChanged is published when a reaction is added to or removed from a message.
|
||||
type ReactionChanged struct {
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Emoji string `json:"emoji"`
|
||||
}
|
||||
|
||||
// envelope is the wire format for every message, both inbound and outbound.
|
||||
type envelope struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
@@ -84,6 +84,67 @@ func (h *Hub) PublishRead(conversationID, userID, messageID int64) {
|
||||
h.sendToConversation(conversationID, envelope{Type: EventRead, Data: mustMarshal(evt)}, userID)
|
||||
}
|
||||
|
||||
// Subscribe adds a user to a conversation's subscriber set so they receive its
|
||||
// events without reconnecting (used when a member is added to a group).
|
||||
func (h *Hub) Subscribe(userID, conversationID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.subs[conversationID] == nil {
|
||||
h.subs[conversationID] = make(map[int64]struct{})
|
||||
}
|
||||
h.subs[conversationID][userID] = struct{}{}
|
||||
}
|
||||
|
||||
// Unsubscribe removes a user from a conversation's subscriber set.
|
||||
func (h *Hub) Unsubscribe(userID, conversationID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
users := h.subs[conversationID]
|
||||
delete(users, userID)
|
||||
if len(users) == 0 {
|
||||
delete(h.subs, conversationID)
|
||||
}
|
||||
}
|
||||
|
||||
// SendToUser delivers an event to every connection of a single user.
|
||||
func (h *Hub) SendToUser(userID int64, evtType string, data any) {
|
||||
payload, err := json.Marshal(envelope{Type: evtType, Data: mustMarshal(data)})
|
||||
if err != nil {
|
||||
slog.Error("ws: marshal direct event", "error", err)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for c := range h.conns[userID] {
|
||||
c.enqueueRaw(payload)
|
||||
}
|
||||
}
|
||||
|
||||
// PublishMemberChanged tells a conversation's members that someone joined or left.
|
||||
func (h *Hub) PublishMemberChanged(conversationID, userID int64, added bool) {
|
||||
evtType := EventMemberAdded
|
||||
if !added {
|
||||
evtType = EventMemberRemoved
|
||||
}
|
||||
evt := MemberChanged{ConversationID: conversationID, UserID: userID}
|
||||
h.sendToConversation(conversationID, envelope{Type: evtType, Data: mustMarshal(evt)}, 0)
|
||||
}
|
||||
|
||||
// PublishReactionChanged tells a conversation's members that a reaction changed.
|
||||
func (h *Hub) PublishReactionChanged(conversationID, messageID, userID int64, emoji string, added bool) {
|
||||
evtType := EventReactionAdded
|
||||
if !added {
|
||||
evtType = EventReactionRemoved
|
||||
}
|
||||
evt := ReactionChanged{
|
||||
ConversationID: conversationID,
|
||||
MessageID: messageID,
|
||||
UserID: userID,
|
||||
Emoji: emoji,
|
||||
}
|
||||
h.sendToConversation(conversationID, envelope{Type: evtType, Data: mustMarshal(evt)}, 0)
|
||||
}
|
||||
|
||||
func (h *Hub) register(c *client, conversationIDs []int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
@@ -10,13 +10,14 @@ import (
|
||||
|
||||
"messenger/internal/api"
|
||||
"messenger/internal/auth"
|
||||
"messenger/internal/media"
|
||||
"messenger/internal/platform/config"
|
||||
"messenger/internal/realtime"
|
||||
"messenger/internal/store"
|
||||
)
|
||||
|
||||
// New builds an *http.Server with all routes and middleware applied.
|
||||
func New(cfg *config.Config, log *slog.Logger, st *store.Store, tm *auth.TokenManager, hub *realtime.Hub) *http.Server {
|
||||
func New(cfg *config.Config, log *slog.Logger, st *store.Store, tm *auth.TokenManager, hub *realtime.Hub, ms media.Storage) *http.Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -32,7 +33,7 @@ func New(cfg *config.Config, log *slog.Logger, st *store.Store, tm *auth.TokenMa
|
||||
static = spaHandler{staticDir: cfg.StaticDir, log: log}
|
||||
}
|
||||
|
||||
mux.Handle("/", api.NewHandler(st, tm, hub, log, cfg, static))
|
||||
mux.Handle("/", api.NewHandler(st, tm, hub, log, cfg, ms, static))
|
||||
|
||||
handler := logRequests(log, recoverPanics(cors(cfg.AllowedOrigins, mux)))
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ func (s *Store) CreateDM(ctx context.Context, userA, userB int64) (*Conversation
|
||||
// Check existing DM
|
||||
var conv Conversation
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT c.id, c.type, c.created_at
|
||||
SELECT c.id, c.type, c.name, c.avatar, c.created_at
|
||||
FROM conversations c
|
||||
JOIN conversation_members m1 ON m1.conversation_id=c.id AND m1.user_id=$1
|
||||
JOIN conversation_members m2 ON m2.conversation_id=c.id AND m2.user_id=$2
|
||||
WHERE c.type='dm'
|
||||
LIMIT 1
|
||||
`, userA, userB).Scan(&conv.ID, &conv.Type, &conv.CreatedAt)
|
||||
`, userA, userB).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt)
|
||||
if err == nil {
|
||||
return &conv, nil
|
||||
}
|
||||
@@ -42,8 +42,8 @@ func (s *Store) CreateDM(ctx context.Context, userA, userB int64) (*Conversation
|
||||
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO conversations(type) VALUES('dm')
|
||||
RETURNING id, type, created_at
|
||||
`).Scan(&conv.ID, &conv.Type, &conv.CreatedAt)
|
||||
RETURNING id, type, name, avatar, created_at
|
||||
`).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -68,8 +68,8 @@ func (s *Store) CreateDM(ctx context.Context, userA, userB int64) (*Conversation
|
||||
func (s *Store) GetConversationByID(ctx context.Context, id int64) (*Conversation, error) {
|
||||
var conv Conversation
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, type, created_at FROM conversations WHERE id=$1
|
||||
`, id).Scan(&conv.ID, &conv.Type, &conv.CreatedAt)
|
||||
SELECT id, type, name, avatar, created_at FROM conversations WHERE id=$1
|
||||
`, id).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -118,7 +118,7 @@ func (s *Store) ConversationIDsForUser(ctx context.Context, userID int64) ([]int
|
||||
// ListConversationsForUser returns summaries of conversations for a user.
|
||||
func (s *Store) ListConversationsForUser(ctx context.Context, userID int64) ([]ConversationSummary, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.id, c.type, c.created_at,
|
||||
SELECT c.id, c.type, c.name, c.avatar, c.created_at,
|
||||
m.id, m.conversation_id, m.sender_id, m.body, m.created_at, m.edited_at, m.deleted_at,
|
||||
uc.unread_count
|
||||
FROM conversation_members cm
|
||||
@@ -156,7 +156,7 @@ func (s *Store) ListConversationsForUser(ctx context.Context, userID int64) ([]C
|
||||
msgDeleted *time.Time
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&cs.ID, &cs.Type, &cs.CreatedAt,
|
||||
&cs.ID, &cs.Type, &cs.Name, &cs.Avatar, &cs.CreatedAt,
|
||||
&msgID, &msgConvID, &msgSender, &msgBody, &msgCreated, &msgEdited, &msgDeleted,
|
||||
&cs.UnreadCount,
|
||||
); err != nil {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// CreateGroup creates a group conversation with the creator as owner and
|
||||
// the given members added, returning the created conversation.
|
||||
func (s *Store) CreateGroup(ctx context.Context, creatorID int64, name string, memberIDs []int64) (*Conversation, error) {
|
||||
conn, err := s.pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var conv Conversation
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO conversations(type, name) VALUES('group', $1)
|
||||
RETURNING id, type, name, avatar, created_at
|
||||
`, name).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO conversation_members(conversation_id, user_id, role)
|
||||
VALUES($1, $2, 'owner')
|
||||
`, conv.ID, creatorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := map[int64]struct{}{creatorID: {}}
|
||||
for _, uid := range memberIDs {
|
||||
if _, ok := seen[uid]; ok {
|
||||
continue
|
||||
}
|
||||
seen[uid] = struct{}{}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO conversation_members(conversation_id, user_id, role)
|
||||
VALUES($1, $2, 'member')
|
||||
ON CONFLICT (conversation_id, user_id) DO NOTHING
|
||||
`, conv.ID, uid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
// AddMember adds a user to a conversation; it is a no-op if already a member.
|
||||
func (s *Store) AddMember(ctx context.Context, conversationID, userID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO conversation_members(conversation_id, user_id, role)
|
||||
VALUES($1, $2, 'member')
|
||||
ON CONFLICT (conversation_id, user_id) DO NOTHING
|
||||
`, conversationID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveMember removes a user from a conversation; it is a no-op if absent.
|
||||
func (s *Store) RemoveMember(ctx context.Context, conversationID, userID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM conversation_members WHERE conversation_id=$1 AND user_id=$2
|
||||
`, conversationID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// MemberRole returns the role of a user in a conversation, or ErrNotFound.
|
||||
func (s *Store) MemberRole(ctx context.Context, conversationID, userID int64) (string, error) {
|
||||
var role string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT role FROM conversation_members WHERE conversation_id=$1 AND user_id=$2
|
||||
`, conversationID, userID).Scan(&role)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// ListMembers returns the members of a conversation with their profiles.
|
||||
func (s *Store) ListMembers(ctx context.Context, conversationID int64) ([]MemberInfo, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT u.id, u.username, u.display_name, u.avatar, u.created_at, cm.role, cm.joined_at
|
||||
FROM conversation_members cm
|
||||
JOIN users u ON u.id = cm.user_id
|
||||
WHERE cm.conversation_id = $1
|
||||
ORDER BY cm.joined_at, u.id
|
||||
`, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []MemberInfo
|
||||
for rows.Next() {
|
||||
var m MemberInfo
|
||||
if err := rows.Scan(
|
||||
&m.User.ID, &m.User.Username, &m.User.DisplayName, &m.User.Avatar, &m.User.CreatedAt,
|
||||
&m.Role, &m.JoinedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func TestIntegration(t *testing.T) {
|
||||
t.Fatalf("ListConversationsForUser: %v, %v", sums, err)
|
||||
}
|
||||
|
||||
msg, err := db.InsertMessage(ctx, conv.ID, u1.ID, "hello")
|
||||
msg, err := db.InsertMessage(ctx, conv.ID, u1.ID, "hello", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InsertMessage: %v", err)
|
||||
}
|
||||
@@ -111,4 +111,74 @@ func TestIntegration(t *testing.T) {
|
||||
if err := db.UpdateLastRead(ctx, conv.ID, u2.ID); err != nil {
|
||||
t.Fatalf("UpdateLastRead: %v", err)
|
||||
}
|
||||
|
||||
// Groups
|
||||
u3, err := db.CreateUser(ctx, "user3", "User Three", "hash3")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser u3: %v", err)
|
||||
}
|
||||
group, err := db.CreateGroup(ctx, u1.ID, "Family", []int64{u2.ID, u1.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroup: %v", err)
|
||||
}
|
||||
if group.Type != "group" || group.Name != "Family" {
|
||||
t.Fatalf("CreateGroup result = %+v", group)
|
||||
}
|
||||
members, err := db.ListMembers(ctx, group.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMembers: %v", err)
|
||||
}
|
||||
if len(members) != 2 {
|
||||
t.Fatalf("ListMembers len = %d, want 2", len(members))
|
||||
}
|
||||
role, err := db.MemberRole(ctx, group.ID, u1.ID)
|
||||
if err != nil || role != "owner" {
|
||||
t.Fatalf("MemberRole u1 = %q, %v; want owner", role, err)
|
||||
}
|
||||
if err := db.AddMember(ctx, group.ID, u3.ID); err != nil {
|
||||
t.Fatalf("AddMember: %v", err)
|
||||
}
|
||||
if err := db.AddMember(ctx, group.ID, u3.ID); err != nil {
|
||||
t.Fatalf("AddMember idempotent: %v", err)
|
||||
}
|
||||
members, _ = db.ListMembers(ctx, group.ID)
|
||||
if len(members) != 3 {
|
||||
t.Fatalf("ListMembers after add = %d, want 3", len(members))
|
||||
}
|
||||
if err := db.RemoveMember(ctx, group.ID, u3.ID); err != nil {
|
||||
t.Fatalf("RemoveMember: %v", err)
|
||||
}
|
||||
members, _ = db.ListMembers(ctx, group.ID)
|
||||
if len(members) != 2 {
|
||||
t.Fatalf("ListMembers after remove = %d, want 2", len(members))
|
||||
}
|
||||
|
||||
// Reactions
|
||||
if err := db.AddReaction(ctx, msg.ID, u2.ID, "👍"); err != nil {
|
||||
t.Fatalf("AddReaction: %v", err)
|
||||
}
|
||||
if err := db.AddReaction(ctx, msg.ID, u2.ID, "👍"); err != nil {
|
||||
t.Fatalf("AddReaction idempotent: %v", err)
|
||||
}
|
||||
if err := db.AddReaction(ctx, msg.ID, u1.ID, "❤️"); err != nil {
|
||||
t.Fatalf("AddReaction u1: %v", err)
|
||||
}
|
||||
reactions, err := db.ReactionsForMessages(ctx, []int64{msg.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ReactionsForMessages: %v", err)
|
||||
}
|
||||
if len(reactions) != 2 {
|
||||
t.Fatalf("ReactionsForMessages len = %d, want 2", len(reactions))
|
||||
}
|
||||
if err := db.RemoveReaction(ctx, msg.ID, u2.ID, "👍"); err != nil {
|
||||
t.Fatalf("RemoveReaction: %v", err)
|
||||
}
|
||||
reactions, _ = db.ReactionsForMessages(ctx, []int64{msg.ID})
|
||||
if len(reactions) != 1 {
|
||||
t.Fatalf("ReactionsForMessages after remove = %d, want 1", len(reactions))
|
||||
}
|
||||
belongs, err := db.MessageBelongsToConversation(ctx, conv.ID, msg.ID)
|
||||
if err != nil || !belongs {
|
||||
t.Fatalf("MessageBelongsToConversation = %v, %v; want true", belongs, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Media stores metadata of an uploaded file. The file bytes live in the
|
||||
// media storage backend (see internal/media), referenced by StorageKey.
|
||||
type Media struct {
|
||||
ID int64
|
||||
UploaderID int64
|
||||
Filename string
|
||||
ContentType string
|
||||
Size int64
|
||||
StorageKey string
|
||||
ThumbKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateMedia inserts a media record and returns it with its ID.
|
||||
func (s *Store) CreateMedia(ctx context.Context, m Media) (*Media, error) {
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO media(uploader_id, filename, content_type, size, storage_key, thumb_key)
|
||||
VALUES($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at
|
||||
`, m.UploaderID, m.Filename, m.ContentType, m.Size, m.StorageKey, nullableString(m.ThumbKey)).Scan(&m.ID, &m.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// GetMediaByID retrieves a media record or returns ErrNotFound.
|
||||
func (s *Store) GetMediaByID(ctx context.Context, id int64) (*Media, error) {
|
||||
var m Media
|
||||
var thumbKey *string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, uploader_id, filename, content_type, size, storage_key, thumb_key, created_at
|
||||
FROM media WHERE id=$1
|
||||
`, id).Scan(&m.ID, &m.UploaderID, &m.Filename, &m.ContentType, &m.Size, &m.StorageKey, &thumbKey, &m.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if thumbKey != nil {
|
||||
m.ThumbKey = *thumbKey
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func nullableString(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -8,14 +8,15 @@ import (
|
||||
)
|
||||
|
||||
// InsertMessage creates a new message in a conversation and returns it.
|
||||
func (s *Store) InsertMessage(ctx context.Context, conversationID, senderID int64, body string) (*Message, error) {
|
||||
// mediaID, when non-nil, attaches a previously uploaded media object.
|
||||
func (s *Store) InsertMessage(ctx context.Context, conversationID, senderID int64, body string, mediaID *int64) (*Message, error) {
|
||||
var m Message
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO messages(conversation_id, sender_id, body)
|
||||
VALUES($1, $2, $3)
|
||||
RETURNING id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
`, conversationID, senderID, body).Scan(
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body,
|
||||
INSERT INTO messages(conversation_id, sender_id, body, media_id)
|
||||
VALUES($1, $2, $3, $4)
|
||||
RETURNING id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at
|
||||
`, conversationID, senderID, body, mediaID).Scan(
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body, &m.MediaID,
|
||||
&m.CreatedAt, &m.EditedAt, &m.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -28,10 +29,10 @@ func (s *Store) InsertMessage(ctx context.Context, conversationID, senderID int6
|
||||
func (s *Store) GetMessageByID(ctx context.Context, id int64) (*Message, error) {
|
||||
var m Message
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
SELECT id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at
|
||||
FROM messages WHERE id=$1
|
||||
`, id).Scan(
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body,
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body, &m.MediaID,
|
||||
&m.CreatedAt, &m.EditedAt, &m.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -50,14 +51,14 @@ func (s *Store) ListMessages(ctx context.Context, conversationID int64, before i
|
||||
var err error
|
||||
if before > 0 {
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
SELECT id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at
|
||||
FROM messages
|
||||
WHERE conversation_id=$1 AND id<$2
|
||||
ORDER BY id DESC LIMIT $3
|
||||
`, conversationID, before, limit)
|
||||
} else {
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
SELECT id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at
|
||||
FROM messages
|
||||
WHERE conversation_id=$1
|
||||
ORDER BY id DESC LIMIT $2
|
||||
@@ -72,7 +73,7 @@ func (s *Store) ListMessages(ctx context.Context, conversationID int64, before i
|
||||
for rows.Next() {
|
||||
var m Message
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body,
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body, &m.MediaID,
|
||||
&m.CreatedAt, &m.EditedAt, &m.DeletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 0002_groups.sql
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS avatar TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE conversation_members ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'member';
|
||||
CREATE INDEX IF NOT EXISTS idx_members_conversation ON conversation_members(conversation_id);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 0003_reactions.sql
|
||||
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (message_id, user_id, emoji)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_message ON message_reactions(message_id);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 0004_media.sql
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
uploader_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size BIGINT NOT NULL,
|
||||
storage_key TEXT NOT NULL UNIQUE,
|
||||
thumb_key TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_uploader ON media(uploader_id);
|
||||
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS media_id BIGINT REFERENCES media(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,71 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Reaction represents a single emoji reaction on a message.
|
||||
type Reaction struct {
|
||||
MessageID int64
|
||||
UserID int64
|
||||
Emoji string
|
||||
}
|
||||
|
||||
// AddReaction adds a reaction to a message; it is a no-op if it already exists.
|
||||
func (s *Store) AddReaction(ctx context.Context, messageID, userID int64, emoji string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO message_reactions(message_id, user_id, emoji)
|
||||
VALUES($1, $2, $3)
|
||||
ON CONFLICT (message_id, user_id, emoji) DO NOTHING
|
||||
`, messageID, userID, emoji)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveReaction removes a reaction from a message; it is a no-op if absent.
|
||||
func (s *Store) RemoveReaction(ctx context.Context, messageID, userID int64, emoji string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM message_reactions WHERE message_id=$1 AND user_id=$2 AND emoji=$3
|
||||
`, messageID, userID, emoji)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReactionsForMessages returns all reactions for the given message IDs.
|
||||
func (s *Store) ReactionsForMessages(ctx context.Context, messageIDs []int64) ([]Reaction, error) {
|
||||
if len(messageIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT message_id, user_id, emoji
|
||||
FROM message_reactions
|
||||
WHERE message_id = ANY($1)
|
||||
ORDER BY message_id, created_at
|
||||
`, messageIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reactions []Reaction
|
||||
for rows.Next() {
|
||||
var r Reaction
|
||||
if err := rows.Scan(&r.MessageID, &r.UserID, &r.Emoji); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reactions = append(reactions, r)
|
||||
}
|
||||
return reactions, nil
|
||||
}
|
||||
|
||||
// MessageBelongsToConversation reports whether a message belongs to a conversation.
|
||||
func (s *Store) MessageBelongsToConversation(ctx context.Context, conversationID, messageID int64) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM messages WHERE id=$1 AND conversation_id=$2
|
||||
)
|
||||
`, messageID, conversationID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
@@ -42,9 +42,18 @@ type Session struct {
|
||||
type Conversation struct {
|
||||
ID int64
|
||||
Type string
|
||||
Name string
|
||||
Avatar string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MemberInfo describes a conversation participant together with their profile.
|
||||
type MemberInfo struct {
|
||||
User User
|
||||
Role string
|
||||
JoinedAt time.Time
|
||||
}
|
||||
|
||||
// ConversationSummary provides a conversation with its last message and unread count.
|
||||
type ConversationSummary struct {
|
||||
Conversation
|
||||
@@ -58,6 +67,7 @@ type Message struct {
|
||||
ConversationID int64
|
||||
SenderID int64
|
||||
Body string
|
||||
MediaID *int64
|
||||
CreatedAt time.Time
|
||||
EditedAt *time.Time
|
||||
DeletedAt *time.Time
|
||||
|
||||
Reference in New Issue
Block a user