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