Files
Messenger/server/internal/api/media.go
T

173 lines
4.7 KiB
Go
Raw Normal View History

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)
}