mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-22 18:43:33 +00:00
Merge pull request #217 from abhinavxd/feat-fs-urls
feat: Signed URL support for filesystem store
This commit is contained in:
+2
-2
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+35
-4
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user