173 lines
4.7 KiB
Go
173 lines
4.7 KiB
Go
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)
|
|
}
|