big update: added reactions, groups, media share
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-27 21:59:48 +03:00
parent ae24bdd0e0
commit 9d03ea4c51
41 changed files with 2548 additions and 172 deletions
+14 -2
View File
@@ -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)
+268 -20
View File
@@ -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),
}
}
+172
View File
@@ -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)
}
+93
View File
@@ -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])
}