Merge pull request #217 from abhinavxd/feat-fs-urls

feat: Signed URL support for filesystem store
This commit is contained in:
Abhinav Raut
2026-01-05 15:59:49 +05:30
committed by GitHub
8 changed files with 165 additions and 7 deletions
+2 -2
View File
@@ -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.
+7
View File
@@ -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
View File
@@ -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))
}
+59
View File
@@ -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)
}
}
+1
View File
@@ -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.",
+9
View File
@@ -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 {
+46 -1
View File
@@ -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.
+6
View File
@@ -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
}