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