@@ -8,12 +8,16 @@ import (
88 "errors"
99 "fmt"
1010 "io"
11+ "net/http"
1112 "os"
13+ "strings"
1214 "time"
1315
1416 "github.com/abhinavxd/libredesk/internal/dbutil"
1517 "github.com/abhinavxd/libredesk/internal/envelope"
18+ "github.com/abhinavxd/libredesk/internal/image"
1619 "github.com/abhinavxd/libredesk/internal/media/models"
20+ "github.com/gabriel-vasile/mimetype"
1721 "github.com/google/uuid"
1822 "github.com/jmoiron/sqlx"
1923 "github.com/knadh/go-i18n"
3034type Store interface {
3135 Put (name , contentType string , content io.ReadSeeker ) (string , error )
3236 Delete (name string ) error
33- GetURL (name string ) string
37+ GetURL (name , disposition , fileName string ) string
3438 GetBlob (name string ) ([]byte , error )
3539 Name () string
3640}
@@ -78,8 +82,13 @@ type queries struct {
7882
7983// UploadAndInsert uploads file on storage and inserts an entry in db.
8084func (m * Manager ) UploadAndInsert (srcFilename , contentType , contentID string , modelType null.String , modelID null.Int , content io.ReadSeeker , fileSize int , disposition null.String , meta []byte ) (models.Media , error ) {
81- var uuid = uuid .New ()
82- _ , err := m .Upload (uuid .String (), contentType , content )
85+ var (
86+ uuid = uuid .New ()
87+ err error
88+ )
89+
90+ // Override content type after upload (in case it was detected incorrectly).
91+ _ , contentType , err = m .Upload (uuid .String (), contentType , content )
8392 if err != nil {
8493 return models.Media {}, err
8594 }
@@ -92,14 +101,24 @@ func (m *Manager) UploadAndInsert(srcFilename, contentType, contentID string, mo
92101 return media , nil
93102}
94103
95- // Upload saves the media file to the storage backend and returns the generated filename.
96- func (m * Manager ) Upload (fileName , contentType string , content io.ReadSeeker ) (string , error ) {
104+ // Upload saves the media file to the storage backend - returns the generated filename and content type (after detection).
105+ func (m * Manager ) Upload (fileName , contentType string , content io.ReadSeeker ) (string , string , error ) {
106+ // On store file is named by UUID to avoid collisions and the actual filename is stored in DB.
107+ m .lo .Debug ("detecting content type for file before upload" , "uuid" , fileName , "source_content_type" , contentType )
108+
109+ // Detect content type and override if needed.
110+ contentType , err := m .detectContentType (contentType , content )
111+ if err != nil {
112+ m .lo .Error ("error detecting content type" , "error" , err )
113+ return "" , "" , err
114+ }
115+
97116 fName , err := m .store .Put (fileName , contentType , content )
98117 if err != nil {
99118 m .lo .Error ("error uploading media" , "error" , err )
100- return "" , envelope .NewError (envelope .GeneralError , m .i18n .Ts ("globals.messages.errorUploading" , "name" , "{globals.terms.media}" ), nil )
119+ return "" , "" , envelope .NewError (envelope .GeneralError , m .i18n .Ts ("globals.messages.errorUploading" , "name" , "{globals.terms.media}" ), nil )
101120 }
102- return fName , nil
121+ return fName , contentType , nil
103122}
104123
105124// Insert inserts media details into the database and returns the inserted media record.
@@ -122,7 +141,7 @@ func (m *Manager) Get(id int, uuid string) (models.Media, error) {
122141 m .lo .Error ("error fetching media" , "error" , err )
123142 return media , envelope .NewError (envelope .GeneralError , m .i18n .Ts ("globals.messages.errorFetching" , "name" , "{globals.terms.media}" ), nil )
124143 }
125- media .URL = m .store . GetURL (media .UUID )
144+ media .URL = m .GetURL (media .UUID , media . ContentType , media . Filename )
126145 return media , nil
127146}
128147
@@ -145,8 +164,15 @@ func (m *Manager) GetBlob(name string) ([]byte, error) {
145164}
146165
147166// GetURL returns the URL for accessing a media file by its name.
148- func (m * Manager ) GetURL (name string ) string {
149- return m .store .GetURL (name )
167+ func (m * Manager ) GetURL (uuid , contentType , fileName string ) string {
168+ // Keep some content types inline.
169+ disposition := "attachment"
170+ if strings .HasPrefix (contentType , "image/" ) ||
171+ strings .HasPrefix (contentType , "video/" ) ||
172+ contentType == "application/pdf" {
173+ disposition = "inline"
174+ }
175+ return m .store .GetURL (uuid , disposition , fileName )
150176}
151177
152178// Attach associates a media file with a specific model by its ID and model name.
@@ -177,6 +203,12 @@ func (m *Manager) Delete(name string) error {
177203 return envelope .NewError (envelope .GeneralError , m .i18n .Ts ("globals.messages.errorDeleting" , "name" , "{globals.terms.media}" ), nil )
178204 }
179205 }
206+
207+ // Thumbnail files do not exist in the database, only in the storage backend, so return early.
208+ if strings .HasPrefix (name , image .ThumbPrefix ) {
209+ return nil
210+ }
211+
180212 // Delete the media record from the database.
181213 if _ , err := m .queries .Delete .Exec (name ); err != nil {
182214 m .lo .Error ("error deleting media from db" , "error" , err )
@@ -214,7 +246,65 @@ func (m *Manager) deleteUnlinkedMessageMedia() error {
214246 m .lo .Error ("error deleting unlinked media" , "error" , err )
215247 continue
216248 }
217- // TODO: If it's an image also delete the `thumb_uuid` image.
249+
250+ // If it's an image, also delete the `thumb_uuid` image from store.
251+ if strings .HasPrefix (mm .ContentType , "image/" ) {
252+ thumbUUID := image .ThumbPrefix + mm .UUID
253+ m .lo .Debug ("deleting thumbnail for unlinked media" , "thumb_uuid" , thumbUUID )
254+ if err := m .Delete (thumbUUID ); err != nil {
255+ m .lo .Error ("error deleting thumbnail for unlinked media" , "error" , err )
256+ }
257+ }
218258 }
219259 return nil
220260}
261+
262+ // detectContentType detects the content type of a file.
263+ // It trusts the source content type unless it's a generic type like application/octet-stream.
264+ // For generic types, it uses http.DetectContentType (stdlib) as a fast path,
265+ // falling back to mimetype library for deeper inspection using magic numbers.
266+ func (m * Manager ) detectContentType (sourceContentType string , content io.ReadSeeker ) (string , error ) {
267+ // Set default if empty
268+ if sourceContentType == "" {
269+ sourceContentType = "application/octet-stream"
270+ }
271+
272+ // Trust source unless it's a generic/useless type
273+ if sourceContentType != "application/octet-stream" &&
274+ sourceContentType != "application/data" &&
275+ sourceContentType != "application/binary" {
276+ m .lo .Debug ("detected media content type from trusted source" , "detected_type" , sourceContentType )
277+ return sourceContentType , nil
278+ }
279+
280+ // Ensure we're at the start
281+ content .Seek (0 , io .SeekStart )
282+
283+ // Fast path: stdlib
284+ buf := make ([]byte , 512 )
285+ n , _ := content .Read (buf )
286+ detected := http .DetectContentType (buf [:n ])
287+
288+ // If stdlib gives a useful type, use it.
289+ // stdlib defaults to application/octet-stream for unknown types.
290+ if detected != "application/octet-stream" {
291+ content .Seek (0 , io .SeekStart )
292+ m .lo .Debug ("detected media content type using stdlib" , "detected_type" , detected , "source_type" , sourceContentType )
293+ return detected , nil
294+ }
295+
296+ // Slow path: mimetype library
297+ content .Seek (0 , io .SeekStart )
298+ mtype , err := mimetype .DetectReader (content )
299+ if err != nil {
300+ m .lo .Error ("error detecting content type" , "error" , err )
301+ content .Seek (0 , io .SeekStart )
302+ return sourceContentType , nil
303+ }
304+
305+ detectedType := mtype .String ()
306+ m .lo .Debug ("detected media content type using mimetype lib" , "detected_type" , detectedType , "source_type" , sourceContentType )
307+
308+ content .Seek (0 , io .SeekStart )
309+ return detectedType , nil
310+ }
0 commit comments