diff --git a/cmd/handlers.go b/cmd/handlers.go index 970d5555..c5b518ac 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -26,8 +26,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Public config for app initialization. g.GET("/api/v1/config", handleGetConfig) - // Media. - g.GET("/uploads/{uuid}", auth(handleServeMedia)) + // Media - supports both authenticated access and signed URLs. + g.GET("/uploads/{uuid}", authOrSignedURL(handleServeMedia)) g.POST("/api/v1/media", auth(handleMediaUpload)) // Settings. diff --git a/cmd/init.go b/cmd/init.go index af6d8d4b..9e4c209e 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -480,6 +480,11 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n, settings *setting.Manager) *media.M log.Fatalf("error initializing s3 media store: %v", err) } case "fs": + // Default expiry to 1h if not set. + fsExpiry := ko.Duration("upload.fs.expiry") + if fsExpiry == 0 { + fsExpiry = 1 * time.Hour + } store, err = fs.New(fs.Opts{ UploadURI: "/uploads", UploadPath: filepath.Clean(ko.String("upload.fs.upload_path")), @@ -491,6 +496,8 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n, settings *setting.Manager) *media.M } return rootURL }, + SigningKey: ko.MustString("app.encryption_key"), + Expiry: fsExpiry, }) if err != nil { log.Fatalf("error initializing fs media store: %v", err) diff --git a/cmd/media.go b/cmd/media.go index 426764dd..190eefda 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -13,6 +13,7 @@ import ( amodels "github.com/abhinavxd/libredesk/internal/auth/models" "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/image" + mmodels "github.com/abhinavxd/libredesk/internal/media/models" "github.com/abhinavxd/libredesk/internal/stringutil" "github.com/google/uuid" "github.com/valyala/fasthttp" @@ -148,20 +149,29 @@ func handleMediaUpload(r *fastglue.Request) error { } // handleServeMedia serves uploaded media. +// Supports both authenticated access (with permission checks) and signed URL access (no permission checks). func handleServeMedia(r *fastglue.Request) error { var ( - app = r.Context.(*App) - auser = r.RequestCtx.UserValue("user").(amodels.User) - uuid = r.RequestCtx.UserValue("uuid").(string) + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("uuid").(string) + authMethod = r.RequestCtx.UserValue("auth_method") ) + // If accessed via signed URL, skip permission checks and serve file directly. + if authMethod == "signed_url" { + return serveMediaFile(r, app, uuid, nil) + } + + // Session/API key authenticated - perform full permission check. + auser := r.RequestCtx.UserValue("user").(amodels.User) + user, err := app.user.GetAgent(auser.ID, "") if err != nil { return sendErrorEnvelope(r, err) } // Fetch media from DB. - media, err := app.media.Get(0, strings.TrimPrefix(uuid, image.ThumbPrefix)) + media, err := getMediaByUUID(app, uuid) if err != nil { return sendErrorEnvelope(r, err) } @@ -187,6 +197,22 @@ func handleServeMedia(r *fastglue.Request) error { if !allowed { return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError) } + + return serveMediaFile(r, app, uuid, &media) +} + +// serveMediaFile serves the actual file content based on the storage provider. +// If media is nil, it will be fetched from DB. +func serveMediaFile(r *fastglue.Request, app *App, uuid string, media *mmodels.Media) error { + // Fetch media metadata from DB if not provided. + if media == nil { + m, err := getMediaByUUID(app, uuid) + if err != nil { + return sendErrorEnvelope(r, err) + } + media = &m + } + consts := app.consts.Load().(*constants) switch consts.UploadProvider { case "fs": @@ -213,3 +239,8 @@ func handleServeMedia(r *fastglue.Request) error { func bytesToMegabytes(bytes int64) float64 { return float64(bytes) / 1024 / 1024 } + +// getMediaByUUID fetches media metadata from DB, handling thumbnail prefix. +func getMediaByUUID(app *App, uuid string) (mmodels.Media, error) { + return app.media.Get(0, strings.TrimPrefix(uuid, image.ThumbPrefix)) +} diff --git a/cmd/middlewares.go b/cmd/middlewares.go index b56ca5c4..c016e035 100644 --- a/cmd/middlewares.go +++ b/cmd/middlewares.go @@ -2,6 +2,7 @@ package main import ( "net/http" + "strconv" "strings" amodels "github.com/abhinavxd/libredesk/internal/auth/models" @@ -220,3 +221,61 @@ func notAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandle return handler(r) } } + +// authOrSignedURL allows access if user is authenticated OR if URL has valid signature. +// Used for media endpoints that support both access methods. +func authOrSignedURL(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { + return func(r *fastglue.Request) error { + app := r.Context.(*App) + + // First, try to authenticate normally. + user, err := authenticateUser(r, app) + if err == nil && user.ID > 0 { + // User is authenticated, set user context and proceed. + r.RequestCtx.SetUserValue("user", amodels.User{ + ID: user.ID, + Email: user.Email.String, + FirstName: user.FirstName, + LastName: user.LastName, + }) + r.RequestCtx.SetUserValue("auth_method", "session") + return handler(r) + } + + // Authentication failed, check for signed URL. + validator := app.media.SignedURLValidator() + if validator == nil { + // Store doesn't support signed URLs, require auth. + return r.SendErrorEnvelope(http.StatusUnauthorized, + app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError) + } + + // Parse signature and expiry from query params. + sig := string(r.RequestCtx.QueryArgs().Peek("sig")) + expStr := string(r.RequestCtx.QueryArgs().Peek("exp")) + + if sig == "" || expStr == "" { + return r.SendErrorEnvelope(http.StatusUnauthorized, + app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError) + } + + exp, err := strconv.ParseInt(expStr, 10, 64) + if err != nil { + return r.SendErrorEnvelope(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalid", "name", "expiry"), nil, envelope.InputError) + } + + // Get the UUID from the route. + uuid := r.RequestCtx.UserValue("uuid").(string) + + // Validate signature. + if !validator(uuid, sig, exp) { + return r.SendErrorEnvelope(http.StatusForbidden, + app.i18n.T("media.invalidOrExpiredURL"), nil, envelope.PermissionError) + } + + // Mark as signed URL access (no user context). + r.RequestCtx.SetUserValue("auth_method", "signed_url") + return handler(r) + } +} diff --git a/i18n/en.json b/i18n/en.json index 75df3db4..6cd49b7a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -355,6 +355,7 @@ "media.fileSizeTooLarge": "File size too large, please upload a file less than {size} ", "media.fileTypeNotAllowed": "File type not allowed", "media.fileEmpty": "This file is 0 bytes, so it will not be attached.", + "media.invalidOrExpiredURL": "Invalid or expired media URL", "inbox.emptyIMAP": "Empty IMAP config", "inbox.emptySMTP": "Empty SMTP config", "inbox.oauthAlreadyExists": "An inbox with this email already exists. Use Reconnect to update credentials.", diff --git a/internal/media/media.go b/internal/media/media.go index fe0b2964..5d19a7d6 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -37,6 +37,9 @@ type Store interface { GetURL(name, disposition, fileName string) string GetBlob(name string) ([]byte, error) Name() string + // SignedURLValidator returns a validator function if the store supports signed URLs. + // Returns nil if the store doesn't use signed URLs (e.g., S3 handles validation itself). + SignedURLValidator() func(name, sig string, exp int64) bool } type Manager struct { @@ -175,6 +178,12 @@ func (m *Manager) GetURL(uuid, contentType, fileName string) string { return m.store.GetURL(uuid, disposition, fileName) } +// SignedURLValidator returns the store's signature validator if available. +// Returns nil if the store doesn't support signed URL validation. +func (m *Manager) SignedURLValidator() func(name, sig string, exp int64) bool { + return m.store.SignedURLValidator() +} + // Attach associates a media file with a specific model by its ID and model name. func (m *Manager) Attach(id int, model string, modelID int) error { if _, err := m.queries.Attach.Exec(id, model, modelID); err != nil { diff --git a/internal/media/stores/localfs/fs.go b/internal/media/stores/localfs/fs.go index 42e3f2c2..e856583a 100644 --- a/internal/media/stores/localfs/fs.go +++ b/internal/media/stores/localfs/fs.go @@ -1,10 +1,14 @@ package fs import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" "fmt" "io" "os" "path/filepath" + "time" "github.com/abhinavxd/libredesk/internal/media" ) @@ -14,6 +18,8 @@ type Opts struct { UploadPath string UploadURI string RootURL func() string + SigningKey string // HMAC signing key for generating signed URLs. + Expiry time.Duration // URL expiry duration. } // Client implements `media.Store` @@ -48,8 +54,47 @@ func (c *Client) Put(filename string, cType string, src io.ReadSeeker) (string, } // GetURL accepts a filename and retrieves the full URL for file. +// If a signing key is configured, returns a signed URL with expiry. func (c *Client) GetURL(name, _, _ string) string { - return fmt.Sprintf("%s%s/%s", c.opts.RootURL(), c.opts.UploadURI, name) + // If no signing key configured, return unsigned URL. + if c.opts.SigningKey == "" { + return fmt.Sprintf("%s%s/%s", c.opts.RootURL(), c.opts.UploadURI, name) + } + return c.signURL(name) +} + +// signURL generates a signed URL with expiry timestamp. +func (c *Client) signURL(name string) string { + exp := time.Now().Add(c.opts.Expiry).Unix() + sig := c.generateSignature(name, exp) + return fmt.Sprintf("%s%s/%s?sig=%s&exp=%d", c.opts.RootURL(), c.opts.UploadURI, name, sig, exp) +} + +// generateSignature creates HMAC-SHA256 signature for the given name and expiry. +func (c *Client) generateSignature(name string, exp int64) string { + message := fmt.Sprintf("%s:%d", name, exp) + h := hmac.New(sha256.New, []byte(c.opts.SigningKey)) + h.Write([]byte(message)) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) +} + +// ValidateSignature verifies the signature and expiry of a signed URL. +// Returns true if the signature is valid and the URL has not expired. +func (c *Client) ValidateSignature(name, sig string, exp int64) bool { + if time.Now().Unix() > exp { + return false + } + expectedSig := c.generateSignature(name, exp) + return hmac.Equal([]byte(sig), []byte(expectedSig)) +} + +// SignedURLValidator returns a validator function if the store supports signed URLs. +// Returns nil if the store doesn't use signed URLs (no signing key configured). +func (c *Client) SignedURLValidator() func(name, sig string, exp int64) bool { + if c.opts.SigningKey == "" { + return nil + } + return c.ValidateSignature } // GetBlob accepts a URL, reads the file, and returns the blob. diff --git a/internal/media/stores/s3/s3.go b/internal/media/stores/s3/s3.go index 3ffa8d8b..e19d5953 100644 --- a/internal/media/stores/s3/s3.go +++ b/internal/media/stores/s3/s3.go @@ -162,3 +162,9 @@ func (c *Client) makeFileURL(name string) string { func (c *Client) Name() string { return "s3" } + +// SignedURLValidator returns nil as S3 handles its own presigned URL validation. +// The S3 service validates presigned URLs when they are accessed. +func (c *Client) SignedURLValidator() func(name, sig string, exp int64) bool { + return nil +}