Files

664 lines
21 KiB
Go

package main
import (
"fmt"
"mime/multipart"
"path/filepath"
"slices"
"strconv"
"strings"
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"
notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/stringutil"
tmpl "github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user/models"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
const (
// Bounds the request body only; the stored file is capped by image.AvatarMaxDim regardless.
maxAvatarSizeMB = 10
)
type resetPasswordRequest struct {
Email string `json:"email"`
}
type setPasswordRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
type availabilityRequest struct {
Status string `json:"status"`
Source string `json:"source"`
}
const availabilitySourceIdle = "idle"
type agentReq struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
SendWelcomeEmail bool `json:"send_welcome_email"`
Teams []string `json:"teams"`
Roles []string `json:"roles"`
Enabled bool `json:"enabled"`
AvailabilityStatus string `json:"availability_status"`
NewPassword string `json:"new_password,omitempty"`
}
// handleGetAgents returns all agents.
func handleGetAgents(r *fastglue.Request) error {
var app = r.Context.(*App)
agents, err := app.user.GetAgents()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agents)
}
// handleGetAgentsCompact returns agents in a compact format, all of them without page params.
func handleGetAgentsCompact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
query = string(r.RequestCtx.QueryArgs().Peek("q"))
userType = string(r.RequestCtx.QueryArgs().Peek("type"))
enabledOnly = string(r.RequestCtx.QueryArgs().Peek("enabled")) == "true"
)
switch userType {
case "", models.UserTypeAgent, models.UserTypeAIAssistant:
default:
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
if ids := getIDsParam(r, "ids"); len(ids) > 0 {
agents, err := app.user.GetAgentsCompactByIDs(ids)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agents)
}
page, pageSize := getOptionalPagination(r)
agents, err := app.user.GetAgentsCompact(query, userType, enabledOnly, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agents)
}
// handleGetAgent returns an agent.
func handleGetAgent(r *fastglue.Request) error {
var app = r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
agent, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleUpdateAgentAvailability updates the current agent availability.
func handleUpdateAgentAvailability(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
availReq availabilityRequest
)
if err := r.Decode(&availReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
agent, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if agent.AvailabilityStatus == availReq.Status {
return r.SendEnvelope(agent)
}
// Idle-driven transitions must never overwrite manual-away states.
if availReq.Source == availabilitySourceIdle &&
(agent.AvailabilityStatus == models.AwayManual || agent.AvailabilityStatus == models.AwayAndReassigning) {
return r.SendEnvelope(agent)
}
if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
return sendErrorEnvelope(r, err)
}
app.user.InvalidateAgentCache(auser.ID)
go app.conversation.BroadcastAgentAvailability(auser.ID, availReq.Status)
// Skip activity log when returning online from idle-away to avoid log spam.
if !(agent.AvailabilityStatus == models.Away && availReq.Status == models.Online) {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, availReq.Status, ip, "", 0); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
agent, err = app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleGetCurrentAgentTeams returns the teams of current agent.
func handleGetCurrentAgentTeams(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
teams, err := app.team.GetUserTeams(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(teams)
}
// handleUpdateCurrentAgent updates the current agent.
func handleUpdateCurrentAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
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"]
// Upload avatar?
if ok && len(files) > 0 {
agent, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := uploadUserAvatar(r, agent, files); err != nil {
return sendErrorEnvelope(r, err)
}
}
// Fetch updated agent and return.
agent, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleCreateAgent creates a new agent.
func handleCreateAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = agentReq{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if err := validateAgentRequest(app, &req); err != nil {
return sendErrorEnvelope(r, err)
}
agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Upsert user teams.
if len(req.Teams) > 0 {
app.team.UpsertUserTeams(agent.ID, req.Teams)
}
if req.SendWelcomeEmail {
// Generate reset token.
resetToken, err := app.user.SetResetPasswordToken(agent.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Render template and send email.
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
"ResetToken": resetToken,
"Email": req.Email,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
}
if err := app.notifier.Send(notifier.Message{
RecipientEmails: []string{req.Email},
Subject: app.i18n.T("globals.messages.welcomeToLibredesk"),
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending notification message", "error", err)
}
}
// Refetch agent as other details might've changed.
agent, err = app.user.GetAgent(agent.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleUpdateAgent updates an agent.
func handleUpdateAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = agentReq{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if err := validateAgentRequest(app, &req); err != nil {
return sendErrorEnvelope(r, err)
}
agent, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// AI assistant identity users are managed via the AI assistant endpoints only.
if agent.Type != models.UserTypeAgent {
return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", app.i18n.T("globals.terms.agent")), nil, envelope.NotFoundError)
}
oldAvailabilityStatus := agent.AvailabilityStatus
// Update agent with individual fields
if err = app.user.UpdateAgent(id, req.FirstName, req.LastName, req.Email, req.Roles, req.Enabled, req.AvailabilityStatus, req.NewPassword); err != nil {
return sendErrorEnvelope(r, err)
}
app.user.InvalidateAgentCache(id)
app.wsHub.KickUser(id)
// Create activity log if user availability status changed.
if oldAvailabilityStatus != req.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, req.AvailabilityStatus, ip, req.Email, id); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
// Log activity if password was changed.
if req.NewPassword != "" {
if err := app.activityLog.PasswordSet(auser.ID, auser.Email, ip, id, req.Email); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
// Upsert agent teams.
if err := app.team.UpsertUserTeams(id, req.Teams); err != nil {
return sendErrorEnvelope(r, err)
}
// Refetch agent and return.
agent, err = app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleDeleteAgent soft deletes an agent.
func handleDeleteAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.user} `id`"), nil, envelope.InputError)
}
// Disallow if self-deleting.
if id == auser.ID {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userCannotDeleteSelf"), nil, envelope.InputError)
}
// Soft delete user.
if err = app.user.SoftDeleteAgent(id); err != nil {
return sendErrorEnvelope(r, err)
}
defer app.wsHub.KickUser(id)
defer app.user.InvalidateAgentCache(id)
// Unassign all open conversations assigned to the user.
if err := app.conversation.UnassignOpen(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleGetCurrentAgent returns the current logged in agent.
func handleGetCurrentAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
u, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(u)
}
// handleDeleteCurrentAgentAvatar deletes the current agent's avatar.
func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
// Get user
agent, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Valid str?
if agent.AvatarURL.String == "" {
return r.SendEnvelope(true)
}
fileName := filepath.Base(agent.AvatarURL.String)
// Delete file from the store.
if err := app.media.Delete(fileName); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.user.UpdateAvatar(agent.ID, ""); err != nil {
return sendErrorEnvelope(r, err)
}
app.user.InvalidateAgentCache(agent.ID)
return r.SendEnvelope(true)
}
// handleResetPassword generates a reset password token and sends an email to the agent.
func handleResetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
resetReq resetPasswordRequest
)
if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
}
// Decode JSON request
if err := r.Decode(&resetReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if resetReq.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
agent, err := app.user.GetAgent(0, resetReq.Email)
if err != nil {
// Send 200 even if user not found, to prevent email enumeration.
return r.SendEnvelope(true)
}
token, err := app.user.SetResetPasswordToken(agent.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Send email.
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplResetPassword, map[string]string{
"ResetToken": token,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingPasswordResetEmail"), nil, envelope.GeneralError)
}
if err := app.notifier.Send(notifier.Message{
RecipientEmails: []string{agent.Email.String},
Subject: "Reset Password",
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending password reset email", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingPasswordResetEmail"), nil, envelope.GeneralError)
}
return r.SendEnvelope(true)
}
// handleSetPassword resets the password with the provided token.
func handleSetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
req setPasswordRequest
)
if ok && agent.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
if req.Password == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.password}"), nil, envelope.InputError)
}
id, err := app.user.ResetPassword(req.Token, req.Password)
if err != nil {
return sendErrorEnvelope(r, err)
}
app.user.InvalidateAgentCache(id)
app.wsHub.KickUser(id)
return r.SendEnvelope(true)
}
// validateAvatarFile checks avatar type and size without side effects.
func validateAvatarFile(r *fastglue.Request, files []*multipart.FileHeader) error {
var app = r.Context.(*App)
if len(files) == 0 {
return nil
}
fileHeader := files[0]
srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(stringutil.SanitizeFilename(fileHeader.Filename))), ".")
if !slices.Contains(image.Exts, srcExt) {
return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.fileTypeisNotAnImage"), nil)
}
if bytesToMegabytes(fileHeader.Size) > maxAvatarSizeMB {
return envelope.NewError(
envelope.InputError,
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
nil,
)
}
return nil
}
// uploadUserAvatar uploads the user avatar.
func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.FileHeader) error {
var app = r.Context.(*App)
if err := validateAvatarFile(r, files); err != nil {
return err
}
fileHeader := files[0]
file, err := fileHeader.Open()
if err != nil {
app.lo.Error("error opening uploaded file", "user_id", user.ID, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.errorUploadingFile"), nil)
}
defer file.Close()
srcFileName := stringutil.SanitizeFilename(fileHeader.Filename)
srcContentType := fileHeader.Header.Get("Content-Type")
resized, err := image.Downscale(image.AvatarMaxDim, file)
if err != nil {
app.lo.Error("error downscaling avatar", "user_id", user.ID, "error", err)
return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.fileTypeisNotAnImage"), nil)
}
srcFileSize := resized.Len()
linkedModel := null.StringFrom(mmodels.ModelUser)
linkedID := null.IntFrom(user.ID)
disposition := null.NewString("", false)
contentID := ""
meta := []byte("{}")
// Agent and AI assistant avatars are public: both appear as authors on the help center.
private := user.Type != models.UserTypeAgent && user.Type != models.UserTypeAIAssistant
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, resized, srcFileSize, disposition, meta, private)
if err != nil {
app.lo.Error("error uploading file", "user_id", user.ID, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.errorUploadingFile"), nil)
}
// Delete current avatar.
if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String)
if err := app.media.Delete(fileName); err != nil {
app.lo.Error("error deleting user avatar", "user_id", user.ID, "error", err)
}
}
if err := app.user.UpdateAvatar(user.ID, "/uploads/"+media.UUID); err != nil {
return sendErrorEnvelope(r, err)
}
app.user.InvalidateAgentCache(user.ID)
return nil
}
// handleGenerateAPIKey generates a new API key for a user
func handleGenerateAPIKey(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
// Check if user exists
user, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
if user.Type != models.UserTypeAgent {
return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", app.i18n.T("globals.terms.agent")), nil, envelope.NotFoundError)
}
// Generate API key and secret
apiKey, apiSecret, err := app.user.GenerateAPIKey(user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
app.user.InvalidateAgentCache(user.ID)
// Return the API key and secret (only shown once)
response := struct {
APIKey string `json:"api_key"`
APISecret string `json:"api_secret"`
}{
APIKey: apiKey,
APISecret: apiSecret,
}
return r.SendEnvelope(response)
}
// handleRevokeAPIKey revokes a user's API key
func handleRevokeAPIKey(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
// Check if user exists
user, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
if user.Type != models.UserTypeAgent {
return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", app.i18n.T("globals.terms.agent")), nil, envelope.NotFoundError)
}
// Revoke API key
if err := app.user.RevokeAPIKey(id); err != nil {
return sendErrorEnvelope(r, err)
}
app.user.InvalidateAgentCache(id)
return r.SendEnvelope(true)
}
func validateAgentRequest(app *App, req *agentReq) error {
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
req.FirstName = strings.TrimSpace(req.FirstName)
req.LastName = strings.TrimSpace(req.LastName)
if req.Email == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil)
}
if !stringutil.ValidEmail(req.Email) {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil)
}
if req.Roles == nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil)
}
if req.FirstName == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil)
}
switch req.AvailabilityStatus {
case "", models.Online, models.Offline, models.Away, models.AwayManual, models.AwayAndReassigning:
default:
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidAvailabilityStatus"), nil)
}
return nil
}