Compare commits

...

3 Commits

6 changed files with 42 additions and 44 deletions

View File

@@ -473,6 +473,8 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager {
UploadURI: "/uploads",
UploadPath: filepath.Clean(ko.String("upload.fs.upload_path")),
RootURL: appRootURL,
Expiry: ko.Duration("upload.fs.expiry"),
Secret: ko.String("upload.fs.secret"),
})
if err != nil {
log.Fatalf("error initializing fs media store: %v", err)
@@ -482,11 +484,10 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager {
}
media, err := media.New(media.Opts{
Store: store,
Lo: lo,
DB: db,
I18n: i18n,
Secret: ko.String("upload.secret"),
Store: store,
Lo: lo,
DB: db,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing media: %v", err)

View File

@@ -1,5 +1,6 @@
<template>
<div class="flex flex-col items-end text-left">
<!-- Do not show live chat continuity emails -->
<div class="flex flex-col items-end text-left" v-if="!message.meta.continuity_email">
<!-- Sender Name -->
<div class="pr-[47px] mb-1">
<p class="text-muted-foreground text-sm font-medium">

View File

@@ -133,7 +133,7 @@ type userStore interface {
type mediaStore interface {
GetBlob(name string) ([]byte, error)
GetURL(name string) string
GetSignedURL(name string, expiresAt time.Time) string
GetSignedURL(name string) string
Attach(id int, model string, modelID int) error
GetByModel(id int, model string) ([]mmodels.Media, error)
ContentIDExists(contentID string) (bool, string, error)
@@ -1308,8 +1308,7 @@ func (m *Manager) BuildWidgetConversationView(conversation models.Conversation)
avatarPath := assignee.AvatarURL.String
if strings.HasPrefix(avatarPath, "/uploads/") {
avatarUUID := strings.TrimPrefix(avatarPath, "/uploads/")
expiresAt := time.Now().Add(1 * time.Hour)
assignee.AvatarURL = null.StringFrom(m.mediaStore.GetSignedURL(avatarUUID, expiresAt))
assignee.AvatarURL = null.StringFrom(m.mediaStore.GetSignedURL(avatarUUID))
}
}
@@ -1366,8 +1365,7 @@ func (m *Manager) BuildWidgetConversationResponse(conversation models.Conversati
// Generate signed URLs for attachments
attachments := msg.Attachments
for j := range attachments {
expiresAt := time.Now().Add(8 * time.Hour)
attachments[j].URL = m.mediaStore.GetSignedURL(attachments[j].UUID, expiresAt)
attachments[j].URL = m.mediaStore.GetSignedURL(attachments[j].UUID)
}
// Fetch sender from cache or store

View File

@@ -998,7 +998,7 @@ func (m *Manager) fetchMessageAttachments(messageID int) (attachment.Attachments
Content: blob,
Size: media.Size,
Header: attachment.MakeHeader(media.ContentType, media.UUID, media.Filename, "base64", media.Disposition.String),
URL: m.mediaStore.GetURL(media.UUID),
URL: m.mediaStore.GetSignedURL(media.UUID),
}
attachments = append(attachments, attachment)
}

View File

@@ -41,8 +41,8 @@ type Store interface {
// This is optional and only implemented by stores that need signed URL functionality (like fs).
type SignedURLStore interface {
Store
GetSignedURL(name string, expiresAt time.Time, secret []byte) string
VerifySignature(name, signature string, expiresAt time.Time, secret []byte) bool
GetSignedURL(name string) string
VerifySignature(name, signature string, expiresAt time.Time) bool
}
type Manager struct {
@@ -50,16 +50,14 @@ type Manager struct {
lo *logf.Logger
i18n *i18n.I18n
queries queries
secret string
}
// Opts provides options for configuring the Manager.
type Opts struct {
Store Store
Lo *logf.Logger
DB *sqlx.DB
I18n *i18n.I18n
Secret string
Store Store
Lo *logf.Logger
DB *sqlx.DB
I18n *i18n.I18n
}
// New initializes and returns a new Manager instance for handling media operations.
@@ -73,7 +71,6 @@ func New(opt Opts) (*Manager, error) {
lo: opt.Lo,
i18n: opt.I18n,
queries: q,
secret: opt.Secret,
}, nil
}
@@ -233,10 +230,10 @@ func (m *Manager) deleteUnlinkedMessageMedia() error {
// GetSignedURL returns a signed URL for accessing a media file with expiration.
// This delegates to the store if it supports signed URLs (like fs), otherwise returns the normal URL.
func (m *Manager) GetSignedURL(name string, expiresAt time.Time) string {
func (m *Manager) GetSignedURL(name string) string {
// Check if the store supports signed URLs
if signedStore, ok := m.store.(SignedURLStore); ok {
return signedStore.GetSignedURL(name, expiresAt, []byte(m.secret))
return signedStore.GetSignedURL(name)
}
// Fallback to regular URL for stores that handle signing internally (like S3)
return m.store.GetURL(name)
@@ -249,32 +246,32 @@ func (m *Manager) VerifySignature(r *fastglue.Request) error {
if uuid == nil {
return fmt.Errorf("missing uuid parameter")
}
signature := string(r.RequestCtx.QueryArgs().Peek("signature"))
expiresStr := string(r.RequestCtx.QueryArgs().Peek("expires"))
if signature == "" || expiresStr == "" {
return fmt.Errorf("missing signature or expires parameter")
}
// Parse expiration time
var expires int64
if _, err := fmt.Sscanf(expiresStr, "%d", &expires); err != nil {
return fmt.Errorf("invalid expires parameter: %v", err)
}
expiresAt := time.Unix(expires, 0)
// Check if store supports signature verification
if signedStore, ok := m.store.(SignedURLStore); ok {
// Strip thumb_ prefix for signature verification to match the base UUID
verificationName := strings.TrimPrefix(uuid.(string), "thumb_")
if !signedStore.VerifySignature(verificationName, signature, expiresAt, []byte(m.secret)) {
if !signedStore.VerifySignature(verificationName, signature, expiresAt) {
return fmt.Errorf("signature verification failed")
}
return nil
}
// For stores that don't support signing (like S3), always allow
return nil
}

View File

@@ -21,6 +21,8 @@ type Opts struct {
UploadPath string
UploadURI string
RootURL string
Expiry time.Duration
Secret string
}
// Client implements `media.Store`
@@ -82,53 +84,52 @@ func (c *Client) Name() string {
// GetSignedURL generates a signed URL for the file with expiration.
// This implements the SignedURLStore interface for secure public access.
func (c *Client) GetSignedURL(name string, expiresAt time.Time, secret []byte) string {
func (c *Client) GetSignedURL(name string) string {
// Generate base URL
baseURL := c.GetURL(name)
// Create the signature payload: name + expires timestamp
expires := expiresAt.Unix()
expires := time.Now().Add(c.opts.Expiry).Unix()
payload := name + strconv.FormatInt(expires, 10)
// Generate HMAC-SHA256 signature
h := hmac.New(sha256.New, secret)
h := hmac.New(sha256.New, []byte(c.opts.Secret))
h.Write([]byte(payload))
signature := base64.URLEncoding.EncodeToString(h.Sum(nil))
// Parse base URL and add query parameters
u, err := url.Parse(baseURL)
if err != nil {
// Fallback to base URL if parsing fails
return baseURL
}
// Add signature and expires parameters
query := u.Query()
query.Set("signature", signature)
query.Set("expires", strconv.FormatInt(expires, 10))
u.RawQuery = query.Encode()
return u.String()
}
// VerifySignature verifies that a signature is valid for the given parameters.
// This implements the SignedURLStore interface for secure public access.
func (c *Client) VerifySignature(name, signature string, expiresAt time.Time, secret []byte) bool {
func (c *Client) VerifySignature(name, signature string, expiresAt time.Time) bool {
// Check if URL has expired
if time.Now().After(expiresAt) {
return false
}
// Recreate the signature payload: name + expires timestamp
expires := expiresAt.Unix()
payload := name + strconv.FormatInt(expires, 10)
// Generate expected HMAC-SHA256 signature
h := hmac.New(sha256.New, secret)
h := hmac.New(sha256.New, []byte(c.opts.Secret))
h.Write([]byte(payload))
expectedSignature := base64.URLEncoding.EncodeToString(h.Sum(nil))
// Use constant-time comparison to prevent timing attacks
return subtle.ConstantTimeCompare([]byte(signature), []byte(expectedSignature)) == 1
}