421 lines
12 KiB
Go
421 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"messenger/internal/realtime"
|
|
"messenger/internal/store"
|
|
)
|
|
|
|
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"`
|
|
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 {
|
|
return messageJSON{
|
|
ID: m.ID,
|
|
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)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
out := make([]conversationJSON, 0, len(summaries))
|
|
for _, s := range summaries {
|
|
item := conversationJSON{
|
|
ID: s.ID,
|
|
Type: s.Type,
|
|
Name: s.Name,
|
|
Avatar: s.Avatar,
|
|
CreatedAt: s.CreatedAt,
|
|
UnreadCount: s.UnreadCount,
|
|
}
|
|
if s.LastMessage != nil {
|
|
msg := toMessageJSON(s.LastMessage)
|
|
item.LastMessage = &msg
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
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 createConversationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
if req.UserID == userID {
|
|
writeError(w, http.StatusBadRequest, "cannot chat with yourself")
|
|
return
|
|
}
|
|
conv, err := h.store.CreateDM(r.Context(), userID, req.UserID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
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) {
|
|
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
|
|
}
|
|
member, err := h.store.IsMember(r.Context(), convID, userID)
|
|
if err != nil || !member {
|
|
writeError(w, http.StatusForbidden, "not a member")
|
|
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
|
|
}
|
|
}
|
|
var before int64
|
|
if v := r.URL.Query().Get("before"); v != "" {
|
|
before, _ = strconv.ParseInt(v, 10, 64)
|
|
}
|
|
|
|
messages, err := h.store.ListMessages(r.Context(), convID, before, limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
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"`
|
|
MediaID *int64 `json:"media_id,omitempty"`
|
|
}
|
|
|
|
func (h *apiHandler) sendMessage(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
|
|
}
|
|
member, err := h.store.IsMember(r.Context(), convID, userID)
|
|
if err != nil || !member {
|
|
writeError(w, http.StatusForbidden, "not a member")
|
|
return
|
|
}
|
|
|
|
var req sendMessageRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
if req.Body == "" && req.MediaID == nil {
|
|
writeError(w, http.StatusBadRequest, "body or media_id is required")
|
|
return
|
|
}
|
|
if len(req.Body) > 4000 {
|
|
writeError(w, http.StatusBadRequest, "body too long (max 4000)")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
|
|
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),
|
|
}
|
|
}
|