big update: added reactions, groups, media share
This commit is contained in:
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user