Files
libredesk/cmd/media.go
T
Abhinav Raut 43ebed1c3a WS rewrite: per-client subscriptions and perf improvements
Targets broadcasts via per-tab list and open subs (validated at subscribe time)
instead of broadcasting message uuid to all agents. Adds frontend list typing, throttled sound,
and many perf improvements to reduce cpu memory & database queries.
Other fixes: Enforce message-to-conversation binding in handleGetMessage
2026-05-20 23:42:07 +05:30

274 lines
9.0 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"slices"
"github.com/abhinavxd/libredesk/internal/attachment"
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"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
// handleMediaUpload handles media uploads.
func handleMediaUpload(r *fastglue.Request) error {
var (
app = r.Context.(*App)
cleanUp = false
)
form, err := r.RequestCtx.MultipartForm()
if err != nil {
app.lo.Error("error parsing form data.", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.GeneralError)
}
files, ok := form.File["files"]
if !ok || len(files) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("validation.notFoundFile"), nil, envelope.InputError)
}
fileHeader := files[0]
file, err := fileHeader.Open()
if err != nil {
app.lo.Error("error reading uploaded file", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
defer file.Close()
// Inline?
var disposition = null.StringFrom(attachment.DispositionAttachment)
inline, ok := form.Value["inline"]
if ok && len(inline) > 0 && inline[0] == "true" {
disposition = null.StringFrom(attachment.DispositionInline)
}
// Linked model?
var linkedModel string
model, ok := form.Value["linked_model"]
if ok && len(model) > 0 {
linkedModel = model[0]
}
// Sanitize filename.
srcFileName := stringutil.SanitizeFilename(fileHeader.Filename)
srcContentType := fileHeader.Header.Get("Content-Type")
srcFileSize := fileHeader.Size
srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
// Check if file is empty
if srcFileSize == 0 {
app.lo.Error("error: uploaded file is empty (0 bytes)")
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileEmpty"), nil, envelope.InputError)
}
// Check file size
consts := app.consts.Load().(*constants)
if bytesToMegabytes(srcFileSize) > float64(consts.MaxFileUploadSizeMB) {
app.lo.Error("error: uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", consts.MaxFileUploadSizeMB)
return r.SendErrorEnvelope(
fasthttp.StatusRequestEntityTooLarge,
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", consts.MaxFileUploadSizeMB)),
nil,
envelope.GeneralError,
)
}
if !slices.Contains(consts.AllowedUploadFileExtensions, "*") && !slices.Contains(consts.AllowedUploadFileExtensions, srcExt) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileTypeNotAllowed"), nil, envelope.InputError)
}
// Delete files on any error.
var uuid = uuid.New()
thumbName := image.ThumbPrefix + uuid.String()
defer func() {
if cleanUp {
app.media.Delete(uuid.String())
app.media.Delete(thumbName)
}
}()
// Generate and upload thumbnail and store image dimensions in the media meta.
var meta = []byte("{}")
if slices.Contains(image.Exts, srcExt) && image.IsImageByContent(file) {
file.Seek(0, 0)
thumbFile, err := image.CreateThumb(image.DefThumbSize, file)
if err != nil {
app.lo.Error("error creating thumb image", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
thumbName, _, err = app.media.Upload(thumbName, srcContentType, thumbFile)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Store image dimensions in media meta, storing dimensions for image previews in future.
file.Seek(0, 0)
width, height, err := image.GetDimensions(file)
if err != nil {
cleanUp = true
app.lo.Error("error getting image dimensions", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorUploadingFile"), nil, envelope.GeneralError)
}
meta, _ = json.Marshal(map[string]interface{}{
"width": width,
"height": height,
})
}
// Reset ptr.
file.Seek(0, 0)
// Override content type after upload (in case it was detected incorrectly).
_, srcContentType, err = app.media.Upload(uuid.String(), srcContentType, file)
if err != nil {
cleanUp = true
app.lo.Error("error uploading file", "error", err)
return sendErrorEnvelope(r, err)
}
// Insert in DB.
media, err := app.media.Insert(disposition, srcFileName, srcContentType, "" /**content_id**/, null.NewString(linkedModel, linkedModel != ""), uuid.String(), null.Int{} /**model_id**/, int(srcFileSize), meta)
if err != nil {
cleanUp = true
app.lo.Error("error inserting metadata into database", "error", err)
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(media)
}
// 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)
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.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Fetch media from DB.
media, err := getMediaByUUID(app, uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check if the user has permission to access the linked model.
allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
if err != nil {
return sendErrorEnvelope(r, err)
}
// For messages, check access to the conversation this message is part of.
// Skip if model_id is not set (media uploaded but not yet attached to a message).
if media.Model.String == mmodels.ModelMessages && media.ModelID.Int > 0 {
conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err = app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
}
if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("status.deniedPermission"), 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
}
forceDownload := string(r.RequestCtx.QueryArgs().Peek("download")) == "1"
consts := app.consts.Load().(*constants)
switch consts.UploadProvider {
case "fs":
disposition := "attachment"
// Inline images/videos/pdfs. SVG excluded.
if !forceDownload &&
media.ContentType != "image/svg+xml" &&
(strings.HasPrefix(media.ContentType, "image/") ||
strings.HasPrefix(media.ContentType, "video/") ||
media.ContentType == "application/pdf") {
disposition = "inline"
}
r.RequestCtx.Response.Header.Set("Content-Type", media.ContentType)
r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, media.Filename))
r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff")
fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid))
case "s3":
url := app.media.GetURL(uuid, media.ContentType, media.Filename)
if forceDownload {
url = app.media.GetURLForDownload(uuid, media.Filename)
}
r.RequestCtx.Redirect(url, http.StatusFound)
}
return nil
}
// bytesToMegabytes converts bytes to megabytes.
func bytesToMegabytes(bytes int64) float64 {
return float64(bytes) / 1024 / 1024
}
// getUnassociatedMedia fetches media by IDs, skipping any already associated with a model.
func getUnassociatedMedia(app *App, ids []int) ([]mmodels.Media, error) {
all, err := app.media.GetMany(ids)
if err != nil {
return nil, err
}
out := make([]mmodels.Media, 0, len(all))
for _, m := range all {
if m.ModelID.Int > 0 {
app.lo.Warn("attachment already associated with another model, skipping", "media_id", m.ID, "model", m.Model.String, "model_id", m.ModelID.Int)
continue
}
out = append(out, m)
}
return out, nil
}
// 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))
}