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 }